@adamjessop/tiktok-live-connector
Version:
Node.js module to receive live stream chat events like comments and gifts from TikTok LIVE
434 lines • 20 kB
JavaScript
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.TikTokLiveConnection = void 0;
const errors_1 = require("../types/errors");
const node_events_1 = require("node:events");
const tiktok_schema_1 = require("../types/tiktok-schema");
const ws_client_1 = __importDefault(require("../lib/ws/lib/ws-client"));
const config_1 = __importDefault(require("../lib/config"));
const utilities_1 = require("../lib/utilities");
const web_1 = require("../lib/web");
const events_1 = require("../types/events");
class TikTokLiveConnection extends node_events_1.EventEmitter {
uniqueId;
signer;
// Public properties
webClient;
wsClient = null;
// Protected properties
_roomInfo = null;
_availableGifts = null;
_connectState = events_1.ConnectState.DISCONNECTED;
options;
/**
* Create a new TikTokLiveConnection instance
* @param {string} uniqueId TikTok username (from URL)
* @param {object} [options] Connection options
* @param {boolean} [options[].authenticateWs=false] Authenticate the WebSocket connection using the session ID from the "sessionid" cookie
* @param {boolean} [options[].processInitialData=true] Process the initital data which includes messages of the last minutes
* @param {boolean} [options[].fetchRoomInfoOnConnect=false] Fetch the room info (room status, streamer info, etc.) on connect (will be returned when calling connect())
* @param {boolean} [options[].enableExtendedGiftInfo=false] Enable this option to get extended information on 'gift' events like gift name and cost
* @param {boolean} [options[].enableRequestPolling=true] Use request polling if no WebSocket upgrade is offered. If `false` an exception will be thrown if TikTok does not offer a WebSocket upgrade.
* @param {number} [options[].requestPollingIntervalMs=1000] Request polling interval if WebSocket is not used
* @param {string} [options[].sessionId=null] The session ID from the "sessionid" cookie is required if you want to send automated messages in the chat.
* @param {object} [options[].webClientParams={}] Custom client params for Webcast API
* @param {object} [options[].webClientHeaders={}] Custom request headers for axios
* @param {object} [options[].websocketHeaders={}] Custom request headers for websocket.client
* @param {object} [options[].webClientOptions={}] Custom request options for axios. Here you can specify an `httpsAgent` to use a proxy and a `timeout` value for example.
* @param {object} [options[].websocketOptions={}] Custom request options for websocket.client. Here you can specify an `agent` to use a proxy and a `timeout` value for example.
* @param {string[]} [options[].preferredAgentIds=[]] Preferred agent IDs to use for the WebSocket connection. If not specified, the default agent IDs will be used.
* @param {boolean} [options[].connectWithUniqueId=false] Connect to the live stream using the unique ID instead of the room ID. If `true`, the room ID will be fetched from the TikTok API.
* @param {boolean} [options[].logFetchFallbackErrors=false] Log errors when falling back to the API or Euler source
* @param {function} [options[].signedWebSocketProvider] Custom function to fetch the signed WebSocket URL. If not specified, the default function will be used.
* @param {EulerSigner} [signer] TikTok Signer instance. If not provided, a new instance will be created using the provided options
*/
constructor(uniqueId, options, signer) {
super();
this.uniqueId = uniqueId;
this.signer = signer;
this.uniqueId = (0, utilities_1.validateAndNormalizeUniqueId)(uniqueId);
// Assign the options
this.options = {
preferredAgentIds: [],
connectWithUniqueId: false,
processInitialData: true,
fetchRoomInfoOnConnect: false,
enableExtendedGiftInfo: false,
enableRequestPolling: true,
requestPollingIntervalMs: 1000,
sessionId: null,
// Override Http client params
webClientParams: {},
webClientHeaders: {},
webClientOptions: {},
// Override WebSocket params
wsClientHeaders: {},
wsClientOptions: {},
wsClientParams: {},
authenticateWs: false,
signedWebSocketProvider: undefined,
logFetchFallbackErrors: false,
...options
};
this.webClient = new web_1.TikTokWebClient({
customHeaders: this.options?.webClientHeaders || {},
axiosOptions: this.options?.webClientOptions,
clientParams: this.options?.webClientParams || {},
authenticateWs: this.options?.authenticateWs || false
}, signer);
this.webClient.cookieJar.sessionId = this.options?.sessionId;
this.setDisconnected();
}
/**
* Set the connection state to disconnected
* @protected
*/
setDisconnected() {
this._connectState = events_1.ConnectState.DISCONNECTED;
this._roomInfo = null;
// Reset the client parameters
this.clientParams.cursor = '';
this.clientParams.room_id = '';
this.clientParams.internal_ext = '';
}
/**
* Get the current Room Info
*/
get roomInfo() {
return this._roomInfo;
}
/**
* Get the available gifts
*/
get availableGifts() {
return this._availableGifts;
}
/**
* Get the current connection state
*/
get isConnecting() {
return this._connectState === events_1.ConnectState.CONNECTING;
}
/**
* Check if the connection is established
*/
get isConnected() {
return this._connectState === events_1.ConnectState.CONNECTED;
}
/**
* Get the current client parameters
*/
get clientParams() {
return this.webClient.clientParams;
}
/**
* Get the current room ID
*/
get roomId() {
return this.webClient.roomId;
}
/**
* Get the current connection state including the cached room info and all available gifts
* (if `enableExtendedGiftInfo` option enabled)
*/
get state() {
return {
isConnected: this.isConnected,
roomId: this.roomId,
roomInfo: this.roomInfo,
availableGifts: this.availableGifts
};
}
/**
* Connects to the live stream of the specified streamer
* @param roomId Room ID to connect to. If not specified, the room ID will be retrieved from the TikTok API
* @returns The current connection state
*/
async connect(roomId) {
switch (this._connectState) {
case events_1.ConnectState.CONNECTED:
throw new errors_1.AlreadyConnectedError('Already connected!');
case events_1.ConnectState.CONNECTING:
throw new errors_1.AlreadyConnectingError('Already connecting!');
default:
case events_1.ConnectState.DISCONNECTED:
try {
this._connectState = events_1.ConnectState.CONNECTING;
await this._connect(roomId);
this._connectState = events_1.ConnectState.CONNECTED;
this.emit(events_1.ControlEvent.CONNECTED, this.state);
return this.state;
}
catch (err) {
this._connectState = events_1.ConnectState.DISCONNECTED;
this.handleError(err, 'Error while connecting');
throw err;
}
}
}
/**
* Connects to the live stream of the specified streamer
*
* @param roomId Room ID to connect to. If not specified, the room ID will be retrieved from the TikTok API
* @protected
*/
async _connect(roomId) {
// First we set the Room ID
if (!this.options.connectWithUniqueId || this.options.fetchRoomInfoOnConnect || this.options.enableExtendedGiftInfo) {
this.clientParams.room_id = roomId || this.clientParams.room_id || await this.fetchRoomId();
}
// <Optional> Fetch Room Info
if (this.options?.fetchRoomInfoOnConnect) {
this._roomInfo = await this.fetchRoomInfo();
if (this._roomInfo.status === 4) {
throw new errors_1.UserOfflineError('LIVE has ended');
}
}
// <Optional> Fetch Gift Info
if (this.options?.enableExtendedGiftInfo) {
this._availableGifts = await this.fetchAvailableGifts();
}
// <Required> Fetch initial room info. Let the user specify their own backend for signing, if they don't want to use Euler
const webcastResponse = await (this.options.signedWebSocketProvider || this.webClient.fetchSignedWebSocketFromEuler)({
roomId: (roomId || !this.options.connectWithUniqueId) ? this.roomId : undefined,
uniqueId: this.options.connectWithUniqueId ? this.uniqueId : undefined,
preferredAgentIds: this.options.preferredAgentIds,
sessionId: this.options.authenticateWs ? this.options.sessionId : undefined
});
// <Optional> Process the initial data
if (this.options?.processInitialData) {
await this.processWebcastResponse(webcastResponse);
}
// If we didn't receive a cursor
if (!webcastResponse.cursor) {
throw new errors_1.InvalidResponseError('Missing cursor in initial fetch response.');
}
// Update client parameters
this.clientParams.cursor = webcastResponse.cursor;
this.clientParams.internal_ext = webcastResponse.internalExt;
// Connect to the WebSocket
const wsParams = {
compress: 'gzip',
room_id: this.roomId,
internal_ext: webcastResponse.internalExt,
cursor: webcastResponse.cursor
};
webcastResponse.wsParams.forEach((wsParam) => wsParams[wsParam.name] = wsParam.value);
this.wsClient = await this.setupWebsocket(webcastResponse.wsUrl, wsParams);
this.emit(events_1.ControlEvent.WEBSOCKET_CONNECTED, this.wsClient);
}
/**
* Disconnects the connection to the live stream
*/
async disconnect() {
if (this.isConnected) {
await this.wsClient?.close();
}
}
/**
* Fetch the room ID from the TikTok API
* @param uniqueId Optional unique ID to use instead of the current one
*/
async fetchRoomId(uniqueId) {
let errors = [];
uniqueId ||= this.uniqueId;
// Method 1
try {
const roomInfo = await this.webClient.fetchRoomInfoFromHtml({ uniqueId: uniqueId });
const roomId = roomInfo.liveRoomUserInfo.liveRoom.roomId;
if (!roomId)
throw new Error('Failed to extract roomId from HTML.');
}
catch (ex) {
this.options.logFetchFallbackErrors && console.error('Failed to retrieve roomId from main page, falling back to API source...');
errors.push(ex);
}
// Method 2 (API Fallback)
try {
const roomData = await this.webClient.fetchRoomInfoFromApiLive({ uniqueId: uniqueId });
if (this.uniqueId === uniqueId) {
this.webClient.roomId = roomData.data.user.roomId;
}
}
catch (err) {
this.options.logFetchFallbackErrors && console.error('Failed to retrieve roomId from API source, falling back to Euler source...');
errors.push(err);
}
// Method 3 (Euler Fallback)
try {
await this.webClient.fetchRoomIdFromEuler({ uniqueId: uniqueId });
}
catch (err) {
errors.push(err);
throw new errors_1.ExtractRoomIdError(errors, `Failed to retrieve room_id from all sources. ${err.message}`);
}
return this.roomId;
}
async fetchIsLive() {
const errors = [];
const isOnline = (status) => status !== 4;
// Method 1 (HTML)
try {
const roomInfo = await this.webClient.fetchRoomInfoFromHtml({ uniqueId: this.uniqueId });
if (roomInfo?.liveRoomUserInfo?.liveRoom?.status === undefined)
throw new Error('Failed to extract status from HTML.');
return isOnline(roomInfo?.liveRoomUserInfo?.liveRoom?.status);
}
catch (ex) {
this.options.logFetchFallbackErrors && console.error('Failed to retrieve room info from main page, falling back to API source...', ex);
errors.push(ex);
}
// Method 2 (API)
try {
const roomData = await this.webClient.fetchRoomInfoFromApiLive({ uniqueId: this.uniqueId });
if (roomData?.data?.liveRoom?.status === undefined)
throw new Error('Failed to extract status from API.');
return isOnline(roomData?.data?.liveRoom?.status);
}
catch (err) {
this.options.logFetchFallbackErrors && console.error('Failed to retrieve room info from API source, falling back to Euler source...', err);
errors.push(err);
}
// Method 3 (Euler)
try {
const roomData = await this.webClient.fetchRoomIdFromEuler({ uniqueId: this.uniqueId });
if (roomData.code !== 200)
throw new Error('Failed to extract status from Euler.');
return roomData.is_live;
}
catch (err) {
this.options.logFetchFallbackErrors && console.error('Failed to retrieve room info from Euler source...', err);
errors.push(err);
throw new errors_1.FetchIsLiveError(errors, `Failed to retrieve room_id from all sources. ${err.message}`);
}
}
/**
* Wait until the streamer is live
* @param seconds Number of seconds to wait before checking if the streamer is live again
*/
async waitUntilLive(seconds = 60) {
seconds = Math.max(30, seconds);
return new Promise(async (resolve) => {
const fetchIsLive = async () => {
const isLive = await this.fetchIsLive();
if (isLive) {
clearInterval(interval);
resolve();
}
};
const interval = setInterval(async () => fetchIsLive(), seconds * 1000);
await fetchIsLive();
});
}
/**
* Get the current room info (including streamer info, room status and statistics)
* @returns Promise that will be resolved when the room info has been retrieved from the API
*/
async fetchRoomInfo() {
if (!this.webClient.roomId)
await this.fetchRoomId();
this._roomInfo = await this.webClient.fetchRoomInfo();
return this._roomInfo;
}
/**
* Get the available gifts in the current room
* @returns Promise that will be resolved when the available gifts have been retrieved from the API
*/
async fetchAvailableGifts() {
try {
let response = await this.webClient.getJsonObjectFromWebcastApi('gift/list/', this.clientParams);
return response.data.gifts;
}
catch (err) {
throw new errors_1.InvalidResponseError(`Failed to fetch available gifts. ${err.message}`, err);
}
}
/**
* Setup the WebSocket connection
*
* @param wsUrl WebSocket URL
* @param wsParams WebSocket parameters
* @returns Promise that will be resolved when the WebSocket connection is established
* @protected
*/
async setupWebsocket(wsUrl, wsParams) {
return new Promise((resolve, reject) => {
// Instantiate the client
const wsClient = new ws_client_1.default(wsUrl, this.webClient.cookieJar, { ...config_1.default.DEFAULT_WS_CLIENT_PARAMS, ...this.options.wsClientParams, ...wsParams }, { ...config_1.default.DEFAULT_WS_CLIENT_HEADERS, ...this.options?.wsClientHeaders }, this.options?.wsClientOptions);
// Handle the connection
wsClient.on('connect', (ws) => {
clearTimeout(connectTimeout);
ws.on('error', (e) => this.handleError(e, 'WebSocket Error'));
ws.on('close', () => {
this.setDisconnected();
this.emit(events_1.ControlEvent.DISCONNECTED);
});
resolve(wsClient);
});
wsClient.on('connectFailed', (err) => reject(`Websocket connection failed, ${err}`));
wsClient.on('webcastResponse', (msg) => this.processWebcastResponse(msg));
wsClient.on('messageDecodingFailed', (err) => this.handleError(err, 'Websocket message decoding failed'));
const connectTimeout = setTimeout(() => reject('Websocket not responding'), 20000);
});
}
async processWebcastResponse(webcastResponse) {
// Emit Raw Data
webcastResponse.messages.forEach((message) => this.emit(events_1.ControlEvent.RAW_DATA, message.type, message.binary));
// Process and emit decoded data depending on the message type
for (let message of webcastResponse.messages) {
const messageData = message.decodedData || {};
// Emit a decoded data event
this.emit(events_1.ControlEvent.DECODED_DATA, message.type, message.decodedData || {}, message.binary);
// Attempt to get it from the map
const basicEvent = events_1.WebcastEventMap[message.type];
if (basicEvent) {
this.emit(basicEvent, messageData);
return;
}
// Handle custom events
switch (message.type) {
case 'WebcastControlMessage':
const controlMessage = messageData;
if (controlMessage.action === tiktok_schema_1.ControlAction.CONTROL_ACTION_STREAM_SUSPENDED || controlMessage.action === tiktok_schema_1.ControlAction.CONTROL_ACTION_STREAM_ENDED) {
this.emit(events_1.WebcastEvent.STREAM_END, { action: controlMessage.action });
await this.disconnect();
}
break;
case 'WebcastGiftMessage':
// Add extended gift info if option enabled
if (Array.isArray(this.availableGifts) && messageData.giftId) {
messageData.extendedGiftInfo = this.availableGifts.find((x) => x.id === messageData.giftId);
}
this.emit(events_1.WebcastEvent.GIFT, messageData);
break;
case 'WebcastSocialMessage':
this.emit(events_1.WebcastEvent.SOCIAL, messageData);
if (messageData.displayType?.includes('follow')) {
this.emit(events_1.WebcastEvent.FOLLOW, messageData);
}
if (messageData.displayType?.includes('share')) {
this.emit(events_1.WebcastEvent.SHARE, messageData);
}
break;
}
}
}
/**
* Handle the error event
*
* @param exception Exception object
* @param info Additional information about the error
* @protected
*/
handleError(exception, info) {
if (this.listenerCount(events_1.ControlEvent.ERROR) < 1) {
return;
}
this.emit(events_1.ControlEvent.ERROR, { info, exception });
}
}
exports.TikTokLiveConnection = TikTokLiveConnection;
//# sourceMappingURL=client.js.map