first commit! 🎉
This commit is contained in:
commit
a7b6a4573a
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
**/.DS_Store
|
||||||
|
node_modules/
|
||||||
|
test/
|
25
README.md
Normal file
25
README.md
Normal 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
67
package-lock.json
generated
Normal 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
19
package.json
Normal 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
325
server.js
Normal 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
76
static/css/chat.css
Normal 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
92
static/js/chat.js
Normal 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
15
views/chat.html
Normal 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>
|
Loading…
Reference in a new issue