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