UNPKG

@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
"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