first commit! 🎉

This commit is contained in:
ari melody 2024-11-03 22:54:37 +00:00
commit a7b6a4573a
Signed by: ari
GPG key ID: CF99829C92678188
8 changed files with 622 additions and 0 deletions

3
.gitignore vendored Normal file
View file

@ -0,0 +1,3 @@
**/.DS_Store
node_modules/
test/

25
README.md Normal file
View file

@ -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`

67
package-lock.json generated Normal file
View file

@ -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
}
}
}
}
}

19
package.json Normal file
View file

@ -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 <ari@arimelody.me>",
"license": "ISC",
"dependencies": {
"ws": "^8.18.0"
},
"devDependencies": {
"@types/ws": "^8.5.13"
}
}

325
server.js Normal file
View file

@ -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("<p>Already authenticated!</p>");
res.write("<a href=\"" + getOAuth2URL() + "\">Log in with another account</a>");
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=<redacted>"; // redact from logs
const code = params.get("code");
const state = params.get("state");
if (!code) {
res.writeHead(400, {"Content-Type": "text/html"});
res.write("<p>No code provided.</p>");
res.write("<a href=\"/\">Log in with Twitch</a>");
res.end();
return;
}
if (state != CSRF_STATE) {
res.writeHead(400, {"Content-Type": "text/html"});
res.write("<p>CSRF state mismatch. This is a very bad thing probably!</p>");
res.write("<a href=\"/\">Log in with Twitch</a>");
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("<p>Authenticated! You may now close this tab.</p>");
res.write("<p>Alternatively, <a href=\"" + getOAuth2URL() + "\">log in with another account</a></p>");
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();

76
static/css/chat.css Normal file
View file

@ -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;
}

92
static/js/chat.js Normal file
View file

@ -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("<SYSTEM> " + 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();

15
views/chat.html Normal file
View file

@ -0,0 +1,15 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title></title>
<link href="css/chat.css" rel="stylesheet">
</head>
<body>
<div id="chat">
</div>
</body>
<script type="module" src="js/chat.js"></script>
</html>