UNPKG

tiktok-live-connector

Version:

Node.js library to receive live stream chat events like comments and gifts from TikTok LIVE.

492 lines 22.4 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 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"); const types_1 = require("../types"); 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: true, enableExtendedGiftInfo: false, enableRequestPolling: true, requestPollingIntervalMs: 1000, sessionId: null, ttTargetIdc: null, signApiKey: null, disableEulerFallbacks: false, // Override Http client params webClientParams: {}, webClientHeaders: {}, webClientOptions: {}, // Override WebSocket params wsClientHeaders: {}, wsClientOptions: {}, wsClientParams: {}, authenticateWs: false, signedWebSocketProvider: undefined, ...options }; this.webClient = new web_1.TikTokWebClient({ customHeaders: this.options?.webClientHeaders || {}, axiosOptions: this.options?.webClientOptions, clientParams: this.options?.webClientParams || {}, authenticateWs: this.options?.authenticateWs || false, signApiKey: this.options?.signApiKey ?? undefined }, signer); this.webClient.cookieJar.setSession(this.options.sessionId, this.options.ttTargetIdc); 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.data.status === 4) { throw new errors_1.UserOfflineError('The requested user isn\'t online :('); } } // <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 protoMessageFetchResult = 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.processProtoMessageFetchResult(protoMessageFetchResult); } // If we didn't receive a cursor if (!protoMessageFetchResult.cursor) { throw new errors_1.InvalidResponseError('Missing cursor in initial fetch response.'); } // Update client parameters this.clientParams.cursor = protoMessageFetchResult.cursor; this.clientParams.internal_ext = protoMessageFetchResult.internalExt; // Connect to the WebSocket const wsParams = { compress: 'gzip', room_id: this.roomId, internal_ext: protoMessageFetchResult.internalExt, cursor: protoMessageFetchResult.cursor, ...protoMessageFetchResult.wsParams }; this.wsClient = await this.setupWebsocket(protoMessageFetchResult.wsUrl, wsParams); // Default app behaviour is to send the im_enter_room message on WebSocket connect this.wsClient.switchRooms(this.roomId); 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 Room ID from HTML.'); return roomId; } catch (ex) { this.handleError(ex, 'Failed to retrieve Room ID from main page, falling back to API source...'); errors.push(ex); } // Method 2 (API Fallback) try { const roomData = await this.webClient.fetchRoomInfoFromApiLive({ uniqueId: uniqueId }); const roomId = roomData?.data?.user?.roomId; if (!roomId) throw new Error('Failed to extract Room ID from API.'); return roomId; } catch (ex) { this.handleError(ex, 'Failed to retrieve Room ID from API source, falling back to Euler source...'); errors.push(ex); } // Method 3 (Euler Fallback) if (!this.options.disableEulerFallbacks) { try { const response = await this.webClient.fetchRoomIdFromEuler({ uniqueId: uniqueId }); if (!response.ok) throw new Error(`Failed to retrieve Room ID from Euler due to an error: ${response.message}`); if (!response.room_id) throw new Error('Failed to extract Room ID from Euler.'); return response.room_id; } catch (err) { this.handleError(err, 'Failed to retrieve Room ID from Euler source, no more sources available...'); errors.push(err); } } // If we reach this point, it means all sources have failed const errMsg = 'Failed to retrieve Room ID from all sources.'; const failErr = new errors_1.FetchIsLiveError(errors, errMsg); this.handleError(failErr, errMsg); throw failErr; } 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.handleError(ex, 'Failed to retrieve room info for live status from main page, falling back to API source...'); 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.handleError(err, 'Failed to retrieve room info for live status from API source, falling back to Euler source...'); errors.push(err); } // Method 3 (Euler) if (!this.options.disableEulerFallbacks) { 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.handleError(err, 'Failed to retrieve room info for live status from Euler source, no more sources available...'); errors.push(err); } } // If we reach this point, it means all sources have failed const errMsg = 'Failed to retrieve live status rom all sources.'; const failErr = new errors_1.FetchIsLiveError(errors, errMsg); this.handleError(failErr, errMsg); throw failErr; } /** * 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); } } /** * Send a message to a TikTok LIVE Room * * @param content Message content to send to the stream * @param options Optional parameters for the message (incl. parameter overrides) */ async sendMessage(content, options) { const roomId = options?.roomId || this.roomId; if (!roomId) { throw new Error('Room ID is required to send a message.'); } const sessionId = options?.sessionId || this.webClient.cookieJar.sessionId; if (!sessionId) { throw new Error('Session ID is required to send a message.'); } const ttTargetIdc = options?.ttTargetIdc || this.webClient.cookieJar.ttTargetIdc; if (!ttTargetIdc) { throw new Error('ttTargetIdc is required to send a message.'); } return this.webClient.sendRoomChatFromEuler({ content: content, roomId: roomId, sessionId: sessionId, ttTargetIdc: ttTargetIdc }); } /** * Set up 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('open', () => { clearTimeout(connectTimeout); wsClient.on('error', (e) => this.handleError(e, 'WebSocket Error')); wsClient.on('close', () => { this.setDisconnected(); this.emit(events_1.ControlEvent.DISCONNECTED); }); resolve(wsClient); }); wsClient.on('error', (err) => reject(`Websocket connection failed, ${err}`)); wsClient.on('protoMessageFetchResult', this.processProtoMessageFetchResult.bind(this)); wsClient.on('imEnteredRoom', (data) => this.emit(events_1.ControlEvent.ENTER_ROOM, data)); wsClient.on('webSocketData', (data) => this.emit(events_1.ControlEvent.WEBSOCKET_DATA, data)); wsClient.on('messageDecodingFailed', (err) => this.handleError(err, 'Websocket message decoding failed')); const connectTimeout = setTimeout(() => reject('Websocket not responding'), 20000); }); } async processProtoMessageFetchResult(protoMessageFetchResult) { for (const message of protoMessageFetchResult.messages) { if (!message.decodedData) { continue; } // Emit the decoded data this.emit(events_1.ControlEvent.DECODED_DATA, message.type, message.decodedData, message.payload); // Process & emit decoded data depending on the message type try { await this.processDecodedData(message.decodedData); } catch (ex) { this.handleError(ex, 'Failed to process decoded data'); } } } async processDecodedData({ data, type }) { // Emit a decoded data event switch (type) { case 'WebcastSocialMessage': if (data.common.displayText.displayType?.includes('follow')) { return this.emit(events_1.WebcastEvent.FOLLOW, data); } if (data.common.displayText.displayType?.includes('share')) { return this.emit(events_1.WebcastEvent.SHARE, data); } // First, emit the raw social message return this.emit(events_1.WebcastEvent.SOCIAL, data); case 'WebcastControlMessage': // Send raw message this.emit(events_1.WebcastEvent.CONTROL_MESSAGE, data); if (data.action === types_1.ControlAction.CONTROL_ACTION_STREAM_ENDED || data.action === types_1.ControlAction.CONTROL_ACTION_STREAM_SUSPENDED) { this.emit(events_1.WebcastEvent.STREAM_END, { action: data.action }); await this.disconnect(); } return; case 'WebcastGiftMessage': // Add extended gift info if available if (Array.isArray(this.availableGifts) && data.giftId) { data.extendedGiftInfo = this.availableGifts.find((x) => x.id === data.giftId); } return this.emit(events_1.WebcastEvent.GIFT, data); default: // Handle all other events const basicEvent = events_1.WebcastEventMap[type]; return basicEvent && this.emit(basicEvent, data); } } /** * 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