diff --git a/TrovoApi.js b/TrovoApi.js new file mode 100644 index 0000000..d91217b --- /dev/null +++ b/TrovoApi.js @@ -0,0 +1,39 @@ +const fetch = require('node-fetch'); + +class TrovoApi { + constructor(clientId, clientSecret) { + if(!clientId) + throw "TrovoApi needs clientId"; + this.clientId = clientId; + this.clientSecret = clientSecret || ''; + } + + getChatToken(channelId) { + return new Promise((resolve, reject) => { + let url = `https://open-api.trovo.live/openplatform/chat/channel-token/${channelId}`; + let headers = { + 'Accept': 'application/json', + 'Client-ID': this.clientId, + }; + fetch(url, { headers }).then(e => e.json()).then(response => { + if(response.error) + throw response.message||response.status; + + resolve(response.token); + }).catch(e => reject(e)); + }); + } + + generateRandomString(length) { + var result = ''; + + var randomChars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; + for(var i = 0; i < length; i++) { + result += randomChars.charAt(Math.floor(Math.random() * randomChars.length)); + } + + return result; + } +} + +module.exports = TrovoApi; diff --git a/TrovoChannel.js b/TrovoChannel.js index 1516754..1b3e07a 100644 --- a/TrovoChannel.js +++ b/TrovoChannel.js @@ -2,7 +2,8 @@ if(typeof process === 'object') { // node.js only var Channel = require('./Channel'); var TrovoPubSubSocket = require('./TrovoPubSubSocket'); - var TrovoSocket = require('./trovo.js/lib/socket/TrovoSocket'); + // var TrovoSocket = require('./trovo.js/lib/socket/TrovoSocket'); + var TrovoSocket = require('./TrovoSocket'); var EventEmitter = require('events'); } @@ -23,12 +24,14 @@ class TrovoChannel extends Channel { _loadGiftData() { this.gifts = {}; + console.log('loadGiftData', this.channelID); this.chat.client.GetGiftShop({ "channelID": this.channelID, "channelOnly": false, "includeOffShelf": true, - "customGift": true + "customGift": false }).then((data) => { + console.log('giftShop', JSON.stringify(data)); for(let info of data.getGiftShop.shopItemInfo) { this.gifts[info.giftInfo.giftID] = { giftName: info.giftInfo.name, @@ -55,6 +58,14 @@ class TrovoChannel extends Channel { } _getTestEvents() { + return { + SuperCap: {"type":"CHAT","channel_info":{"channel_id":"101588242"},"data":{"eid":"1629482589166379_101588242_2886926797_1","chats":[{"type":6,"content":"balls","nick_name":"vampirefrog","avatar":"https://headicon.trovo.live/user/cioq4bqaaaaaabbllzj43qc7cy.jpeg?t=0","medals":["creator"],"roles":["streamer"],"message_id":"1629482589139681695_101588242_101588242_2886927975_1","sender_id":101588242,"uid":101588242,"user_name":"vampirefrog","content_data":{"gift_display_name":"Super Cap","gift_num":1,"magicChatCurrencyInfo":"2,1500","magicChatGiftID":520000995,"normal_emote_enabled":"","user_time":1629482588720},"custom_role":"[{\"roleName\":\"streamer\",\"roleType\":100000}]"}]}}, + StaySafe: {"type":"CHAT","data":{"eid":"1629482969285073_0_2886926853_1","chats":[{"type":5,"content":"{\"gift_id\":520010002,\"gift\":\"Stay Safe\",\"num\":1,\"gift_value\":100,\"value_type\":\"Mana\"}","nick_name":"XSENKU","avatar":"10.png","roles":["follower"],"message_id":"1629480612852370586_100039313_104364132_2886927975_1","sender_id":104364132}]}}, + Join: {"type":"CHAT","channel_info":{"channel_id":"101301301"},"data":{"eid":"1629483241736358_101301301_2887057659_1","chats":[{"type":5004,"content":"just joined channel!","nick_name":"t2kANONYMOUS","avatar":"https://headicon.trovo.live/user/fqwrybqaaaaaayhef65cuwlxcy.jpeg?t=0","roles":["follower"],"message_id":"1629483241418427441_101301301_102509868_2887057787_1","sender_id":102509868,"uid":102509868,"user_name":"t2kAN0NYN0US","content_data":{"chatShowAuth":"100000|100001","chatViewerThreshold":100,"normal_emote_enabled":"","viewers":69},"custom_role":"[{\"roleName\":\"follower\",\"roleType\":100006}]"}]}}, + Follow: {"type":"CHAT","channel_info":{"channel_id":"101301301"},"data":{"eid":"1629483281504129_101301301_2887057659_1","chats":[{"type":5003,"content":"just followed channel!","nick_name":"mvpIMMORTAL","avatar":"https://headicon.trovo.live/default/1.png","roles":["follower"],"message_id":"1629483281187938299_101301301_103085572_2887057787_1","sender_id":103085572,"uid":103085572,"user_name":"mvpIMMORTAL","content_data":{"chatShowAuth":"100000|100001","chatViewerThreshold":100,"normal_emote_enabled":"","viewers":70},"custom_role":"[{\"roleName\":\"follower\",\"roleType\":100006}]"}]}}, + Boost: {"type":"CHAT","data":{"eid":"1629483226522550_0_2886926853_1","chats":[{"type":5007,"content":"{title} has casted a spell rocket and the channel will be boosted to front page!","nick_name":"","message_id":"1629482666829442093_101301301_0_2886927750_1"}]}}, + }; + return { FOLLOW: { unknownInt: 101379536, @@ -318,32 +329,133 @@ class TrovoChannel extends Channel { */ } + _handleChat(chat) { + switch(chat.type) { + case 5003: + this.emit('message', { + type: 'follow', + id: chat.message_id, + original: chat, + username: chat.user_name||chat.nick_name, + displayName: chat.nick_name, + avatar: chat.avatar, + }); + break; + case 5004: + this.emit('message', { + type: 'join', + id: chat.message_id, + original: chat, + username: chat.user_name||chat.nick_name, + displayName: chat.nick_name, + avatar: chat.avatar, + }); + break; + case 5009: + + break; + case 6: + this.emit('message', { + type: 'gift', + id: chat.message_id, + original: chat, + + message: chat.content, + spans: this._parseEmotes(chat.content), + + giftType: chat.content_data.gift_display_name || 'Super Cap', + currency: 'ELIXIR', + value: 1, + amount: chat.content_data.gift_num, + icon: null, + + username: chat.user_name||chat.nick_name, + displayName: chat.nick_name, + avatar: chat.avatar, + }); + break; + case 5: + let data = JSON.parse(chat.content); + let giftData; + if(data.gift_id && (data.gift_id in this.gifts)) + giftData = this.gifts[data.gift_id]; + let message = ''; + this.emit('message', { + type: 'gift', + id: chat.message_id, + original: chat, + + message: message, + spans: this._parseEmotes(message), + + giftType: data.gift, + currency: data.value_type, + value: data.gift_value, + amount: data.num, + icon: giftData&&giftData.icon, + + username: chat.user_name||chat.nick_name, + displayName: chat.nick_name, + avatar: chat.avatar, + }); + break; + default: + var testEvents = this._getTestEvents(); + if(chat.content in testEvents && testEvents[chat.content].data && testEvents[chat.content].data.chats) { + for(let testChat of testEvents[chat.content].data.chats) { + this._handleChat(testChat); + } + } else { + this.emit('message', { + type: 'chat', + id: chat.message_id, + original: chat, + text: chat.content, + spans: this._parseEmotes(chat.content), + username: chat.user_name||chat.nick_name, + displayName: chat.nick_name, + avatar: chat.avatar, + }); + } + } + } + _createChannelWS() { - var testEvents = this._getTestEvents(); + console.log('_createChannelWS'); this.emit('verbose', 'Opening WebSocket ' + this.channelName); - var emitter = new EventEmitter(); - this.ws = new TrovoSocket(this.channelID, emitter); - emitter.on('socketMessage', (event) => { - this.emit('verbose', 'RECV ' + Buffer.from(event.data).toString('base64')); - }); - emitter.on('chatMessage', (message) => { - this.emit('verbose', 'chatMessage ' + JSON.stringify(message)); - if(message.user == this.channelName && testEvents[message.content]) { - this._handleChatEvent(message.content.replace(/[0-9]+$/, ''), testEvents[message.content]); + this.ws = new TrovoSocket(this.chat.settings.clientId, this.channelID); + this.ws.on('verbose', (msg) => { console.log('TROVO SOCKET', msg); }); + this.ws.on('CHAT', (data) => { + if(!data || !data.chats) return; + + for(let chat of data.chats) { + this._handleChat(chat); } - this._handleChatMessage(message); - }); - emitter.on('chatEvent', (type, data) => { - this._handleChatEvent(type, data); }); - emitter.on('socketOpen', (event) => { - this.state = 'joined'; - this.emit('joined', {}); - }); - this.ws.connect(); + // var emitter = new EventEmitter(); + // this.ws = new TrovoSocket(this.channelID, emitter); + // emitter.on('socketMessage', (event) => { + // this.emit('verbose', 'RECV ' + Buffer.from(event.data).toString('base64')); + // }); + // emitter.on('chatMessage', (message) => { + // this.emit('verbose', 'chatMessage ' + JSON.stringify(message)); + // if(message.user == this.channelName && testEvents[message.content]) { + // this._handleChatEvent(message.content.replace(/[0-9]+$/, ''), testEvents[message.content]); + // return; + // } + // this._handleChatMessage(message); + // }); + // emitter.on('chatEvent', (type, data) => { + // this._handleChatEvent(type, data); + // }); + // emitter.on('socketOpen', (event) => { + // this.state = 'joined'; + // this.emit('joined', {}); + // }); + this.pubWs = new TrovoPubSubSocket(this.channelID); this.pubWs.on('MESSAGE', (data) => { if(data && data.data && data.data.message) { @@ -396,6 +508,7 @@ class TrovoChannel extends Channel { } _parseEmotes(str) { + str = str || ''; var spans = []; var state = 'none', emote = '', span = ''; @@ -457,22 +570,24 @@ class TrovoChannel extends Channel { return 'https://headicon.trovo.live/user/'+iconURL+'?max_age=31536000&imageView2/2/w/100/h/100/format/webp' } - _handleChatMessage(message) { - this.emit('message', { - type: 'chat', - id: message.identifier, - original: message, - text: message.content, - spans: this._parseEmotes(message.content), - username: message.accountName, - displayName: message.user, - avatar: this._getAvatarUrl(message.iconURL) - }); - } - _handleChatEvent(type, data) { + console.log('chatEvent', type, data); this.emit('verbose', 'handleChatEvent ' + type + ' ' + JSON.stringify(data)); switch(type) { + case 'CHAT': + for(let chat in data.chats) { + this.emit('message', { + type: 'chat', + id: chat.uid, + original: chat, + text: chat.content, + spans: this._parseEmotes(chat.content), + username: chat.user_name, + displayName: chat.nick_name, + avatar: chat.avatar, + }); + } + break; case 'GIFT': case 'CUSTOM_SPELL': var message, giftId, currency, value, amount, giftName, icon; @@ -542,24 +657,24 @@ class TrovoChannel extends Channel { break; case 'GIFT_SUBS': // this.emit('message', { - // type: 'giftSub', - // id: msg.id, - // original: msg, - // username: msg.sender.username, - // displayName: msg.sender.displayname, - // avatar: msg.sender.avatar, - // receiver: msg.receiver + // type: 'giftSub', + // id: msg.id, + // original: msg, + // username: msg.sender.username, + // displayName: msg.sender.displayname, + // avatar: msg.sender.avatar, + // receiver: msg.receiver // }); break; case 'RECEIVE_SUB': // this.emit('message', { - // type: 'giftSubReceive', - // id: data.id, - // original: data, - // username: data.accountName, - // displayName: data.user, - // avatar: this._getAvatarUrl(data.iconURL), - // gifter: msg.gifter + // type: 'giftSubReceive', + // id: data.id, + // original: data, + // username: data.accountName, + // displayName: data.user, + // avatar: this._getAvatarUrl(data.iconURL), + // gifter: msg.gifter // }); break; case 'SUBSCRIBE_CHANNEL': @@ -574,13 +689,13 @@ class TrovoChannel extends Channel { break; case 'RAID': // this.emit('message', { - // type: 'host', - // id: data.id, - // original: data, - // username: data.accountName, - // displayName: data.user, - // avatar: this._getAvatarUrl(data.iconURL), - // viewers: null // TODO get this? + // type: 'host', + // id: data.id, + // original: data, + // username: data.accountName, + // displayName: data.user, + // avatar: this._getAvatarUrl(data.iconURL), + // viewers: null // TODO get this? // }); break; } @@ -596,9 +711,72 @@ class TrovoChannel extends Channel { }); } - getStatus() { + getStatus(original) { return new Promise((resolve, reject) => { - resolve({ viewers: this.viewerCount, platform: this.chat.getPlatformName() }); + this.chat.client.GetLiveInfo(this.channelName).then((result) => { + if(original) { + resolve(result); + return; + } + + let audienceType = null; + let audienceTypeOptions = [{ + value: "CHANNEL_AUDIENCE_TYPE_FAMILYFRIENDLY", + label: "For all viewers" + }, { + value: "CHANNEL_AUDIENCE_TYPE_TEEN", + label: "13+ content" + }, { + value: "CHANNEL_AUDIENCE_TYPE_EIGHTEENPLUS", + label: "18+ content" + }, { + value: "CHANNEL_AUDIENCE_TYPE_PERSONALVIEWS", + label: "Personal views" + }]; + for(let a of audienceTypeOptions) { + if(result.getLiveInfo.channelInfo.audiType == a.value) { + audienceType = a.label; + } + } + + let ret = { + platform: this.chat.getPlatformName(), + clientId: this.chat.id, + channelId: this.id, + user: { + avatar: result.getLiveInfo.streamerInfo.faceUrl, + displayName: result.getLiveInfo.streamerInfo.nickName + }, + channel: { + description: result.getLiveInfo.streamerInfo.info + }, + stream: { + isLive: result.getLiveInfo.isLive, + startTime: result.getLiveInfo.programInfo.startTm, + viewers: result.getLiveInfo.channelInfo.viewers, + title: result.getLiveInfo.programInfo.title, + preview: result.getLiveInfo.programInfo.coverUrl, + language: result.getLiveInfo.channelInfo.languageName, + audienceType: audienceType, + category: { + id: result.getLiveInfo.categoryInfo.id, + name: result.getLiveInfo.categoryInfo.name, + } + }, + chat: { + + } + }; + + this.chat.client.GetUserFollowCount(result.getLiveInfo.streamerInfo.uid).then((followCountResult) => { + ret.user.followers = followCountResult.getUserFollowCount.followerCount; + ret.user.following = followCountResult.getUserFollowCount.followingCount; + resolve(ret); + }, (err) => { + resolve(ret); + }); + + }, reject); }); } } diff --git a/TrovoChat.js b/TrovoChat.js index 3c196dd..d733af0 100644 --- a/TrovoChat.js +++ b/TrovoChat.js @@ -19,15 +19,20 @@ class TrovoChat extends Chat { this.liveInfoCache = {}; this.client = new TrovoApollo(); - if(this.settings.email && this.settings.password) { + if(this.settings.clientId) { + setTimeout(() => this.emit('ready')); + } else if(this.settings.email && this.settings.password) { + console.log('logging in!'); this.client.login(this.settings.email, this.settings.password).then((login) => { + console.log('logged in', login); this.log('AUTH', login); this.emit('ready'); }).catch((e) => { + console.error(e); throw new Error('Error logging in: ' + e.toString()); }); } else { - this.emit('ready'); + setTimeout(() => this.emit('ready')); } this._loadEmotes(); @@ -61,10 +66,12 @@ class TrovoChat extends Chat { getPlatformName() { return 'trovo'; } _joinChannelByChannelID(channelData) { - var channel = new TrovoChannel(this, channelData); + let channel = new TrovoChannel(this, channelData); channel.addListener('verbose', (str) => this.log(str)); - this.channels.push(channel); + channel.id = channelData.channelID + '-' + this._generateRandomString(8); + + this.channels[channel.id] = channel; return channel; } diff --git a/TrovoSocket.js b/TrovoSocket.js new file mode 100644 index 0000000..2f29ebd --- /dev/null +++ b/TrovoSocket.js @@ -0,0 +1,100 @@ +if(typeof process === 'object') { + var WebSocket = require('isomorphic-ws'); + var EventEmitter = require('events'); + var TrovoApi = require('./TrovoApi'); +} + +class TrovoSocket extends EventEmitter { + constructor(clientId, channelID, settings) { + super(); + + this.channelID = channelID; + + settings = settings || {}; + + this.wsUrl = settings && settings.wsUrl || 'wss://open-chat.trovo.live/chat'; + this.verbose = settings.verbose || false; + this.reconnectDelay = settings.reconnectDelay || 2000; + this.state = 'none'; + console.log('instantiating trovo api', clientId); + this.trovoApi = new TrovoApi(clientId); + this.nonces = {}; + this.pingTimeout = 30000; + } + + connect() { + if(this.webSocket) { + this.webSocket.removeAllListeners('message'); + this.state = 'none'; + this.webSocket = null; + } + + this.emit('verbose', 'Opening WebSocket ' + this.wsUrl); + this.webSocket = new WebSocket(this.wsUrl); + + this.webSocket.once('open', () => { + this.emit('verbose', 'websocket open'); + console.log('getting chat token', this.channelID); + this.trovoApi.getChatToken(this.channelID).then((token) => { + console.log('got token', token); + this.send('AUTH', { token: token }).then((response) => { + this.emit('open'); + this.sendPing(); + }); + }) + }); + + this.webSocket.once('error', (err) => { + this.emit('verbose', 'websocket error: ' + err.toString()); + }); + + this.webSocket.on('close', (err, message) => { + this.emit('verbose', 'websocket close: ' + err + ' ' + message); + setTimeout(() => { this.connect(); }, this.reconnectDelay); + }); + + this.webSocket.on('message', (text) => { + var data = JSON.parse(text); + if(data.type == 'RESPONSE' && data.nonce) { + if(this.nonces && this.nonces[data.nonce]) { + this.nonces[data.nonce].resolve(data.data); + delete this.nonces[data.nonce]; + } + } else if(data.type == 'PONG') { + setTimeout(() => { this.sendPing(); }, this.pingTimeout) + } + + this.emit('verbose', 'message: '+JSON.stringify(data)); + this.emit(data.type, data.data); + }); + } + + sendPing() { + this.send('PING'); + } + + send(type, data) { + if(this.webSocket && this.webSocket.readyState == 1) { + var nonce = type + '_' + Date.now(); + var message = { + type: type, + nonce: nonce, + data: data + }; + var str = JSON.stringify(message); + this.webSocket.send(str); + this.emit('verbose', 'SEND ' + str); + return new Promise((resolve, reject) => { + this.nonces[nonce] = { resolve: resolve, reject: reject }; + }); + } + } + + isConnected() { + return this.state === 'connected'; + } +} + +if(typeof process === 'object') { + module.exports = TrovoSocket; +}