// Parses an IRC message and returns a JSON object with the message's // component parts (tags, source (nick and host), command, parameters). // Expects the caller to pass a single message. (Remember, the Twitch // IRC server may send one or more IRC messages in a single message.) module.exports = function (message) { let parsedMessage = { // Contains the component parts. tags: null, source: null, command: null, parameters: null }; // The start index. Increments as we parse the IRC message. let idx = 0; // The raw components of the IRC message. let rawTagsComponent = null; let rawSourceComponent = null; let rawCommandComponent = null; let rawParametersComponent = null; // If the message includes tags, get the tags component of the IRC message. if (message[idx] === '@') { // The message includes tags. let endIdx = message.indexOf(' '); rawTagsComponent = message.slice(1, endIdx); idx = endIdx + 1; // Should now point to source colon (:). } // Get the source component (nick and host) of the IRC message. // The idx should point to the source part; otherwise, it's a PING command. if (message[idx] === ':') { idx += 1; let endIdx = message.indexOf(' ', idx); rawSourceComponent = message.slice(idx, endIdx); idx = endIdx + 1; // Should point to the command part of the message. } // Get the command component of the IRC message. let endIdx = message.indexOf(':', idx); // Looking for the parameters part of the message. if (-1 == endIdx) { // But not all messages include the parameters part. endIdx = message.length; } rawCommandComponent = message.slice(idx, endIdx).trim(); // Get the parameters component of the IRC message. if (endIdx != message.length) { // Check if the IRC message contains a parameters component. idx = endIdx + 1; // Should point to the parameters part of the message. rawParametersComponent = message.slice(idx); } // Parse the command component of the IRC message. parsedMessage.command = parseCommand(rawCommandComponent); // Only parse the rest of the components if it's a command // we care about; we ignore some messages. if (null == parsedMessage.command) { // Is null if it's a message we don't care about. return null; } else { if (null != rawTagsComponent) { // The IRC message contains tags. parsedMessage.tags = parseTags(rawTagsComponent); } parsedMessage.source = parseSource(rawSourceComponent); parsedMessage.parameters = rawParametersComponent; if (rawParametersComponent && rawParametersComponent[0] === '!') { // The user entered a bot command in the chat window. parsedMessage.command = parseParameters(rawParametersComponent, parsedMessage.command); } } return parsedMessage; } // Parses the tags component of the IRC message. function parseTags(tags) { // badge-info=;badges=broadcaster/1;color=#0000FF;... const tagsToIgnore = { // List of tags to ignore. 'client-nonce': null, 'flags': null }; let dictParsedTags = {}; // Holds the parsed list of tags. // The key is the tag's name (e.g., color). let parsedTags = tags.split(';'); parsedTags.forEach(tag => { let parsedTag = tag.split('='); // Tags are key/value pairs. let tagValue = (parsedTag[1] === '') ? null : parsedTag[1]; switch (parsedTag[0]) { // Switch on tag name case 'badges': case 'badge-info': // badges=staff/1,broadcaster/1,turbo/1; if (tagValue) { let dict = {}; // Holds the list of badge objects. // The key is the badge's name (e.g., subscriber). let badges = tagValue.split(','); badges.forEach(pair => { let badgeParts = pair.split('/'); dict[badgeParts[0]] = badgeParts[1]; }) dictParsedTags[parsedTag[0]] = dict; } else { dictParsedTags[parsedTag[0]] = null; } break; case 'emotes': // emotes=25:0-4,12-16/1902:6-10 if (tagValue) { let dictEmotes = {}; // Holds a list of emote objects. // The key is the emote's ID. let emotes = tagValue.split('/'); emotes.forEach(emote => { let emoteParts = emote.split(':'); let textPositions = []; // The list of position objects that identify // the location of the emote in the chat message. let positions = emoteParts[1].split(','); positions.forEach(position => { let positionParts = position.split('-'); textPositions.push({ startPosition: positionParts[0], endPosition: positionParts[1] }) }); dictEmotes[emoteParts[0]] = textPositions; }) dictParsedTags[parsedTag[0]] = dictEmotes; } else { dictParsedTags[parsedTag[0]] = null; } break; case 'emote-sets': // emote-sets=0,33,50,237 let emoteSetIds = tagValue.split(','); // Array of emote set IDs. dictParsedTags[parsedTag[0]] = emoteSetIds; break; default: // If the tag is in the list of tags to ignore, ignore // it; otherwise, add it. if (tagsToIgnore.hasOwnProperty(parsedTag[0])) { ; } else { dictParsedTags[parsedTag[0]] = tagValue; } } }); return dictParsedTags; } // Parses the command component of the IRC message. function parseCommand(rawCommandComponent) { let parsedCommand = null; commandParts = rawCommandComponent.split(' '); switch (commandParts[0]) { case 'JOIN': case 'PART': case 'NOTICE': case 'CLEARCHAT': case 'HOSTTARGET': case 'PRIVMSG': parsedCommand = { command: commandParts[0], channel: commandParts[1] } break; case 'PING': parsedCommand = { command: commandParts[0] } break; case 'CAP': parsedCommand = { command: commandParts[0], isCapRequestEnabled: (commandParts[2] === 'ACK') ? true : false, // The parameters part of the messages contains the // enabled capabilities. } break; case 'GLOBALUSERSTATE': // Included only if you request the /commands capability. // But it has no meaning without also including the /tags capability. parsedCommand = { command: commandParts[0] } break; case 'USERSTATE': // Included only if you request the /commands capability. case 'ROOMSTATE': // But it has no meaning without also including the /tags capabilities. parsedCommand = { command: commandParts[0], channel: commandParts[1] } break; case 'RECONNECT': console.log('The Twitch IRC server is about to terminate the connection for maintenance.') parsedCommand = { command: commandParts[0] } break; case '421': console.log(`Unsupported IRC command: ${commandParts[2]}`) return null; case '001': // Logged in (successfully authenticated). parsedCommand = { command: commandParts[0], channel: commandParts[1] } break; case '002': // Ignoring all other numeric messages. case '003': case '004': case '353': // Tells you who else is in the chat room you're joining. case '366': case '372': case '375': case '376': console.log(`numeric message: ${commandParts[0]}`) return null; default: console.log(`\nUnexpected command: ${commandParts[0]}\n`); return null; } return parsedCommand; } // Parses the source (nick and host) components of the IRC message. function parseSource(rawSourceComponent) { if (null == rawSourceComponent) { // Not all messages contain a source return null; } else { let sourceParts = rawSourceComponent.split('!'); return { nick: (sourceParts.length == 2) ? sourceParts[0] : null, host: (sourceParts.length == 2) ? sourceParts[1] : sourceParts[0] } } } // Parsing the IRC parameters component if it contains a command (e.g., !dice). function parseParameters(rawParametersComponent, command) { let idx = 0 let commandParts = rawParametersComponent.slice(idx + 1).trim(); let paramsIdx = commandParts.indexOf(' '); if (-1 == paramsIdx) { // no parameters command.botCommand = commandParts.slice(0); } else { command.botCommand = commandParts.slice(0, paramsIdx); command.botCommandParams = commandParts.slice(paramsIdx).trim(); // TODO: remove extra spaces in parameters string } return command; }