irc.mjs
1 import * as util from "./util.mjs" 2 import * as twitch from "./twitch.mjs" 3 import * as events from "node:events" 4 5 const ERROR_FAILED_TO_AUTHENTICATE = new Error("Failed to Authenticate Twitch IRC WebSocket") 6 const ERROR_CONNECTION_CLOSED = new Error("Connection Closed by Remote Target") 7 8 export function ValidateQuestion(questionText) { 9 const failedTests = new Array() 10 , questionMarks = questionText.match(/\?+/g) 11 12 if (questionText.length == 0) { 13 failedTests.push("isEmpty") 14 } 15 if (!(/^[\w,'"\.\-\@\!\?\$\&\\\s]+$/ig).test(questionText)) { 16 failedTests.push("isNotAlphaNumeric") 17 } 18 if (questionMarks && questionMarks.length > 1) { 19 failedTests.push("isManyQuestions") 20 } 21 if (!(/\?/g).test(questionText)) { 22 failedTests.push("isNotQuestion") 23 } 24 if (questionText.length > 240) { 25 failedTests.push("isTooLong") 26 } 27 return failedTests.length == 0 28 } 29 30 export function FilterMessage(parsed) { 31 const { user, message } = parsed 32 33 const isBot = user == "nightbot" || user == "streamelements" 34 , isQuestion = message.includes("?") 35 , hasLink = message.includes("http") 36 , isSignificant = message.length > 15 37 38 return !isBot && !hasLink && isQuestion && isSignificant 39 } 40 41 export function ParseMessage(raw) { 42 const atCharacter = raw.indexOf("@") 43 , firstSpace = raw.indexOf(" ") 44 , firstColon = raw.indexOf(":", firstSpace+1) 45 , secondColon = raw.indexOf(":", firstColon+1) 46 47 const tagsRaw = raw.substring(atCharacter+1, firstColon) 48 , sourceRaw = raw.substring(firstColon+1, secondColon) 49 , message = raw.substring(secondColon+1, raw.length-2) // remove trailing carriage return + newline 50 51 const tags = {} 52 53 for (const tag of tagsRaw.split(";")) { 54 const [key, value] = tag.split("=") 55 tags[key] = value 56 } 57 58 const nickname = sourceRaw.substring(sourceRaw.indexOf("!")+1,sourceRaw.indexOf("@")) 59 60 return { user: nickname, id: +tags["user-id"], bits: +tags.bits || 0, message } 61 } 62 63 export function MakeChatReader() { 64 const ChatReader = {} 65 66 ChatReader.Ongoing = new Map() // userName => connection 67 ChatReader.emitter = new events.EventEmitter() 68 69 ChatReader.emitter.on("start", (userName, consumeMessage) => { 70 const messageHandler = (e) => { 71 const rawMessage = e.data 72 if (rawMessage.includes("PRIVMSG")) { 73 consumeMessage(rawMessage) 74 } 75 } 76 77 ChatReader.makeConnection().then(connection => { 78 connection.addEventListener("message", messageHandler) 79 ChatReader.Ongoing.set(userName, connection) 80 connection.send(`JOIN #${userName}`) 81 }).catch(console.error) 82 }) 83 84 ChatReader.emitter.on("end", (userName) => { 85 if (!ChatReader.Ongoing.has(userName)) return 86 87 const connection = ChatReader.Ongoing.get(userName) 88 89 connection.send(`PART #${userName}`) 90 connection.close(1000) 91 92 ChatReader.Ongoing.delete(userName) 93 }) 94 95 ChatReader.makeConnection = () => { 96 return new Promise((resolve, reject) => { 97 let connection = new WebSocket("wss://irc-ws.chat.twitch.tv:443") 98 99 connection.addEventListener("open", (e) => { 100 connection.send(`CAP REQ :twitch.tv/tags`) 101 connection.send(`PASS oauth:${Bun.env.EBS_SUPPORT_APP_TOKEN}`) 102 connection.send(`NICK offbeatlive`) 103 resolve(connection) 104 }) 105 106 connection.addEventListener("message", (e) => { 107 const rawMessage = e.data 108 const { user, message }= ParseMessage(rawMessage) 109 110 if (message.indexOf("JOIN #")+message.indexOf("PART #") > -2) { 111 console.log(`[IRC] ${message}`) 112 } 113 114 if (rawMessage.includes("Login unsuccessful")) { 115 connection = null 116 reject(ERROR_FAILED_TO_AUTHENTICATE) 117 } 118 119 if (rawMessage.includes("PING")) { connection.send("PONG :tmi.twitch.tv") } 120 }) 121 122 connection.addEventListener("close", () => { 123 console.log(`[IRC] Connection Closed!`) 124 reject(ERROR_CONNECTION_CLOSED) 125 }) 126 connection.addEventListener("error", reject) 127 }) 128 } 129 130 return ChatReader 131 }