stream-tools/server.js
2024-11-03 22:55:22 +00:00

326 lines
10 KiB
JavaScript

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();