commit a7b6a4573a7743b3a842066370fbe162a14def2e Author: ari melody Date: Sun Nov 3 22:54:37 2024 +0000 first commit! 🎉 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c70c9b0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +**/.DS_Store +node_modules/ +test/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..229fd79 --- /dev/null +++ b/README.md @@ -0,0 +1,25 @@ +# ari's stream tools + +experimenting with the twitch API to make handy streaming tools! + +(also featuring the world's most rushed documentation) + +## how to run + +1. `git clone` this repo and `cd` into it +2. `npm ci` to install dependencies +3. set the following environment variables (it will complain if you don't): + - `TWITCH_CLIENT_ID`: the client ID for your twitch application + - `TWITCH_CLIENT_SECRET`: the client secret for your twitch application + - `BROADCASTER_NAME`: the username of the streamer/broadcaster +4. `npm run start` to start +5. follow instructions in the console to hook up a bot/user account +6. ??? +7. profit + +## chat + +- access via `GET /chat` +- append `?system=false` to hide system messages +- for OBS, consider overriding `body`'s CSS to `background: transparent` + diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..31cce98 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,67 @@ +{ + "name": "ari-stream-panels", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "ari-stream-panels", + "version": "1.0.0", + "license": "ISC", + "dependencies": { + "ws": "^8.18.0" + }, + "devDependencies": { + "@types/ws": "^8.5.13" + } + }, + "node_modules/@types/node": { + "version": "22.8.7", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.8.7.tgz", + "integrity": "sha512-LidcG+2UeYIWcMuMUpBKOnryBWG/rnmOHQR5apjn8myTQcx3rinFRn7DcIFhMnS0PPFSC6OafdIKEad0lj6U0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.19.8" + } + }, + "node_modules/@types/ws": { + "version": "8.5.13", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.5.13.tgz", + "integrity": "sha512-osM/gWBTPKgHV8XkTunnegTRIsvF6owmf5w+JtAfOw472dptdm0dlGv4xCt6GwQRcC2XVOvvRE/0bAoQcL2QkA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/undici-types": { + "version": "6.19.8", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz", + "integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==", + "dev": true, + "license": "MIT" + }, + "node_modules/ws": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz", + "integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..e76502e --- /dev/null +++ b/package.json @@ -0,0 +1,19 @@ +{ + "name": "ari-stream-panels", + "version": "1.0.0", + "description": "", + "type": "module", + "main": "server.js", + "scripts": { + "start": "node server.js", + "dev": "nodemon server.js" + }, + "author": "ari melody ", + "license": "ISC", + "dependencies": { + "ws": "^8.18.0" + }, + "devDependencies": { + "@types/ws": "^8.5.13" + } +} diff --git a/server.js b/server.js new file mode 100644 index 0000000..fec4c82 --- /dev/null +++ b/server.js @@ -0,0 +1,325 @@ +import http from "http"; +import fs from "fs"; +import { WebSocket, WebSocketServer } from "ws"; +import path from "path"; + +const BASE_URL = "https://api.twitch.tv"; +const CLIENT_ID = process.env.TWITCH_CLIENT_ID; +const CLIENT_SECRET = process.env.TWITCH_CLIENT_SECRET; +const BROADCASTER_NAME = process.env.TWITCH_BROADCASTER; +const STATIC_DIR = path.join(import.meta.dirname, "static"); + +let BOT_TOKEN = process.env.TWITCH_BOT_TOKEN; +let REFRESH_TOKEN = ""; +let CSRF_STATE = generateCSRFToken(); + +let ws; +let wss; +let clients = []; +let broadcasterInfo; +let botInfo; +let avatarCache = {}; + +const mimeTypes = { + "html": "text/html", + "css": "text/css", + "js": "application/javascript", + "png": "image/png", +}; + +const PORT = 8080; + +function start() { + if (!CLIENT_ID) { + console.error("TWITCH_CLIENT_ID not provided! Exiting..."); + process.exit(1); + } + if (!CLIENT_SECRET) { + console.error("TWITCH_CLIENT_SECRET not provided! Exiting..."); + process.exit(1); + } + if (!BROADCASTER_NAME) { + console.error("BROADCASTER_NAME not provided! Exiting..."); + process.exit(1); + } + + const server = http.createServer((req, res) => { + log_request(req, res, handler) + }) + server.listen(PORT); + + startWebsocketServer(server); + + if (!BOT_TOKEN) { + console.log( + "This software requires a Twitch bot/user account to function.\n" + + `Please go to http://localhost:${PORT} to log in with your bot/user account.`, + ); + } +} + +async function handler(req, res) { + if (req.url == "/") { + if (!BOT_TOKEN) { + res.writeHead(307, { + "Location": getOAuth2URL(), + }); + res.end(); + return; + } + res.writeHead(200, {"Content-Type": "text/html"}); + res.write("

Already authenticated!

"); + res.write("Log in with another account"); + res.end(); + return; + } + + if (req.url.startsWith("/?")) { + await handleOAuth(req, res); + return; + } + + if (req.url.split("?")[0] == "/chat") { + const filepath = path.join(import.meta.dirname, "views", "chat.html"); + fs.readFile(filepath, (err, data) => { + if (err) { + res.writeHead(404, {"Content-Type": "text/plain"}); + res.end("Not Found"); + return; + } + res.writeHead(200, {"Content-Type": "text/html"}); + res.write(data); + res.end(); + }); + return; + } + + var filepath = path.join(STATIC_DIR, req.url.split("?")[0]); + if (!filepath.startsWith(STATIC_DIR)) { + res.writeHead(404, {"Content-Type": "text/plain"}); + res.end("Not Found"); + return; + } + + fs.readFile(filepath, (err, data) => { + if (err) { + res.writeHead(404, {"Content-Type": "text/plain"}); + res.end("Not Found"); + return; + } + + const ext = filepath.substring(filepath.lastIndexOf(".") + 1); + res.writeHead(200, { + "Content-Type": mimeTypes[ext] || "text/plain", + }); + res.write(data); + res.end(); + }); +} + +async function handleOAuth(req, res) { + const params = new URLSearchParams(req.url.substring(2)); + req.url = "/?code="; // redact from logs + const code = params.get("code"); + const state = params.get("state"); + if (!code) { + res.writeHead(400, {"Content-Type": "text/html"}); + res.write("

No code provided.

"); + res.write("Log in with Twitch"); + res.end(); + return; + } + if (state != CSRF_STATE) { + res.writeHead(400, {"Content-Type": "text/html"}); + res.write("

CSRF state mismatch. This is a very bad thing probably!

"); + res.write("Log in with Twitch"); + res.end(); + return; + } + + const oAuthResponse = await getOAuthToken(CLIENT_ID, CLIENT_SECRET, code); + BOT_TOKEN = oAuthResponse.access_token; + REFRESH_TOKEN = oAuthResponse.refresh_token; + CSRF_STATE = generateCSRFToken(); + broadcasterInfo = (await getUsers(CLIENT_ID, BOT_TOKEN, BROADCASTER_NAME))[0]; + botInfo = (await getUsers(CLIENT_ID, BOT_TOKEN, null))[0]; + + console.log("Bot token assigned!"); + + startWebsocket(); + + res.writeHead(200, {"Content-Type": "text/html"}); + res.write("

Authenticated! You may now close this tab.

"); + res.write("

Alternatively, log in with another account

"); + res.end(); +} + +async function startWebsocket() { + if (ws) ws.close(); + ws = new WebSocket("wss://eventsub.wss.twitch.tv/ws"); + ws.addEventListener("open", () => { + console.log("Twitch websocket open!"); + }); + ws.addEventListener("message", msg => { + handleWSMessage(JSON.parse(msg.data.toString())) + }); + ws.addEventListener("close", () => { + console.log("Twitch websocket closed."); + }); + ws.addEventListener("error", err => { + console.error(err); + }); +} + +async function startWebsocketServer(server) { + wss = new WebSocketServer({server: server}); + wss.on("connection", client => { + console.log("New websocket client connected."); + clients.push(client); + + if (!BOT_TOKEN) { + client.send(JSON.stringify({ + type: "system", + message: "Server has not been configured. Check the server console for details.", + })); + } + + client.on("close", () => { + console.log("Websocket client disconnected."); + clients = clients.filter(c => c != client); + }); + }); +} + +async function handleWSMessage(data) { + switch (data.metadata.message_type) { + case 'session_welcome': + hookWSSubscriptions(data.payload.session.id) + case 'notification': + switch(data.metadata.subscription_type) { + case 'channel.chat.message': + let avatarURL = avatarCache[data.payload.event.chatter_user_login] + if (!avatarURL) { + const CHATTER_INFO = (await getUsers( + CLIENT_ID, + BOT_TOKEN, + data.payload.event.chatter_user_login) + )[0]; + avatarCache[CHATTER_INFO.login] = CHATTER_INFO.profile_image_url; + avatarURL = CHATTER_INFO.profile_image_url; + } + console.log(`<${data.payload.event.chatter_user_login}> ${data.payload.event.message.text}`); + clients.forEach(client => { + client.send(JSON.stringify({ + type: "message", + message: { + name: data.payload.event.chatter_user_name, + avatar: avatarURL, + colour: data.payload.event.color, + text: data.payload.event.message.text, + }, + })); + }); + } + } +} + +async function hookWSSubscriptions(sessionID) { + // chat subscription + const res = await fetch(BASE_URL + "/helix/eventsub/subscriptions", { + method: "POST", + headers: { + "Authorization": "Bearer " + BOT_TOKEN, + "Client-Id": CLIENT_ID, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + "type": "channel.chat.message", + "version": "1", + "condition": { + "broadcaster_user_id": broadcasterInfo.id, + "user_id": botInfo.id, + }, + "transport": { + "method": "websocket", + "session_id": sessionID, + } + }), + }); + + if (res.ok) { + console.log("Connected to stream chat for " + broadcasterInfo.login + "."); + clients.forEach(client => { + client.send(JSON.stringify({ + type: "system", + message: "Connected to stream chat for " + broadcasterInfo.login + ".", + })); + }); + } else { + const data = await res.json(); + console.error("Failed to connect to stream chat for " + broadcasterInfo.login + "."); + console.error(JSON.stringify(data)); + } +} + +async function getOAuthToken(clientID, clientSecret, code) { + const res = await fetch("https://id.twitch.tv/oauth2/token", { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + body: "client_id=" + clientID + + "&client_secret=" + clientSecret + + "&code=" + code + + "&grant_type=authorization_code" + + "&redirect_uri=http://localhost:" + PORT, + }); + return await res.json(); +} + +async function getUsers(clientID, token, username) { + let url = BASE_URL + "/helix/users"; + if (username) url += "?login=" + username; + const res = await fetch(url, { + headers: { + "Authorization": "Bearer " + token, + "Client-Id": clientID, + } + }); + const json = await res.json(); + return json.data; +} + +function generateCSRFToken() { + const chars = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"; + const length = 32; + let res = ""; + for (let i = 0; i < length; i++) + res += chars[Math.floor(Math.random() * chars.length)]; + return res; +} + +function getOAuth2URL() { + return "https://id.twitch.tv/oauth2/authorize" + + "?response_type=code" + + "&client_id=" + CLIENT_ID + + "&force_verify=true" + + "&redirect_uri=http://localhost:" + PORT + + "&scope=user:read:chat" + + "&state=" + CSRF_STATE; +} + +async function log_request(req, res, handler) { + const startTime = new Date().getTime(); + await handler(req, res); + const elapsed = new Date().getTime() - startTime; + console.log( + `[${new Date().toISOString()}]`, + req.method, req.url, "-", + res.statusCode, "-", + req.socket.remoteAddress, "-", + `${elapsed}ms` + ); +} + +start(); diff --git a/static/css/chat.css b/static/css/chat.css new file mode 100644 index 0000000..d3fab3a --- /dev/null +++ b/static/css/chat.css @@ -0,0 +1,76 @@ +html { + font-family: "Inter", sans-serif; + font-size: 32px; +} + +body { + margin: 0; + padding: 0; + background: #101010; +} + +#chat { + width: calc(100% - 1rem); + padding: .5rem; + display: flex; + flex-direction: column; + gap: .5rem; +} + +.chat-message { + width: calc(100% - 2.7rem); + margin: 0; + padding: .5rem .8rem .5rem 2rem; + + display: flex; + align-items: start; + gap: .5rem; + + color: #f0f0f0; + background: #202020; + border-radius: .5rem; + text-shadow: 0 0 1rem #f0f0f080; +} + +.system-message { + width: calc(100% - 1.5rem); + margin: 0; + padding: .5rem .8rem; + + display: flex; + align-items: center; + gap: .5rem; + + color: #909090; + background: #202020; + text-shadow: none; + border-radius: .5rem; + + font-size: .8rem; +} + +.chat-avatar { + width: 2rem; + height: 2rem; + border-radius: 100%; + position: absolute; + transform: translate(-2.3rem,-.7rem); +} + +.chat-username { + color: #b7fd49; + color: var(--colour); + text-shadow: 0 0 1rem #b7fd4980; + text-shadow: 0 0 1rem var(--glow_colour); + font-weight: bold; +} + +.system-message .chat-username { + color: #909090; + text-shadow: inherit; + font-weight: bold; +} + +.chat-content { + word-break: break-word; +} diff --git a/static/js/chat.js b/static/js/chat.js new file mode 100644 index 0000000..015b16c --- /dev/null +++ b/static/js/chat.js @@ -0,0 +1,92 @@ +const params = new URLSearchParams(window.location.search); + +function start() { + pushSystemMessage("Connecting to Server..."); + + const ws = new WebSocket("ws://" + window.location.host); + ws.addEventListener("open", () => { + pushSystemMessage("Connected!"); + }); + ws.addEventListener("message", msg => { + handleWSMessage(JSON.parse(msg.data)); + }); + ws.addEventListener("close", () => { + console.log("Websocket connection closed."); + }); + ws.addEventListener("error", err => { + console.error(err); + }); +} + +function handleWSMessage(data) { + switch (data.type) { + case 'message': + pushMessage( + data.message.name, + data.message.avatar, + data.message.colour, + data.message.text, + ); + window.scrollTo(0, document.body.scrollHeight); + break; + case 'system': + pushSystemMessage(data.message); + window.scrollTo(0, document.body.scrollHeight); + break; + default: + console.warn("Received unknown message type \"" + data.type + "\"."); + console.log(data); + break; + } +} + +function pushSystemMessage(content) { + if (params.get("system") == "false") return; + + console.log(" " + content); + + const container = document.createElement("p"); + container.className = "system-message"; + + const username = document.createElement("span"); + username.className = "chat-username"; + username.innerText = "SYSTEM"; + + const contentSpan = document.createElement("span"); + contentSpan.className = "chat-content"; + contentSpan.innerText = content; + + container.appendChild(username); + container.appendChild(contentSpan); + + document.getElementById("chat").appendChild(container); +} + +function pushMessage(username, avatarURL, colour, content) { + console.log(`<${username}> ${content}`); + + const container = document.createElement("p"); + container.className = "chat-message"; + + const avatar = document.createElement("img"); + avatar.className = "chat-avatar"; + avatar.src = avatarURL; + + const usernameSpan = document.createElement("span"); + usernameSpan.className = "chat-username"; + usernameSpan.innerText = username; + usernameSpan.style.setProperty("--colour", colour); // #ff00ff + usernameSpan.style.setProperty("--glow_colour", colour + "80"); // #ff00ff80 + + const contentSpan = document.createElement("span"); + contentSpan.className = "chat-content"; + contentSpan.innerText = content; + + container.appendChild(avatar); + container.appendChild(usernameSpan); + container.appendChild(contentSpan); + + document.getElementById("chat").appendChild(container); +} + +start(); diff --git a/views/chat.html b/views/chat.html new file mode 100644 index 0000000..d459adf --- /dev/null +++ b/views/chat.html @@ -0,0 +1,15 @@ + + + + + + + + + +
+
+ + + +