refactor chatwidget code, add prefetching emotes

This commit is contained in:
2025-07-22 18:40:57 +01:00
parent fb6091c9c0
commit cc3176ea2f
15 changed files with 180 additions and 129 deletions

View File

@@ -6,7 +6,7 @@ CLIENT_SECRET= # Client_secret gotten from the twitch dev console
# REDIRECT_URL= # Redirect URL that has been set in the twitch dev console. Defaults to: http://localhost:3456 # REDIRECT_URL= # Redirect URL that has been set in the twitch dev console. Defaults to: http://localhost:3456
# REDIRECT_PORT= # Redirect port if the REDIRECT_URL has not been set. Defaults to 3456. This is also the port the bot will listen on to authenticate # REDIRECT_PORT= # Redirect port if the REDIRECT_URL has not been set. Defaults to 3456. This is also the port the bot will listen on to authenticate
# COMMAND_PREFIX= # The prefix which will be used to activate commands. Defaults to '!'. When requiring a space between prefix and command, escape the space with a backslash # COMMAND_PREFIX= # The prefix which will be used to activate commands. Defaults to '!'. When requiring a space between prefix and command, escape the space with a backslash
CHATWIDGET_PORT= # The port that the chat widget will be served on WEB_PORT= # The port that the chat widget and sound alerts will be served on
# The Twitch IDs required below can be gotten from this website: https://www.streamweasels.com/tools/convert-twitch-username-to-user-id/ # The Twitch IDs required below can be gotten from this website: https://www.streamweasels.com/tools/convert-twitch-username-to-user-id/

View File

@@ -1,116 +0,0 @@
import { chatterApi, streamerId } from "..";
import logger from "../lib/logger";
import type { twitchEventData } from "./websockettypes";
import chatWidget from "./www/index.html";
type badgeObject = {
[key: string]: {
[key: string]: string;
};
};
type emoteObject = {
[key: string]: string;
}
const port = Number(process.env.CHATWIDGET_PORT);
if (isNaN(port)) { logger.enverr("CHATWIDGET_PORT"); process.exit(1); };
const server = Bun.serve({
port,
fetch(request, server) {
if (server.upgrade(request)) return;
return new Response('oops', { status: 500 });
},
routes: {
"/": chatWidget,
"/getBadges": async () => {
const globalBadges = chatterApi.chat.getGlobalBadges();
const channelBadges = chatterApi.chat.getChannelBadges(streamerId);
const rawBadges = await Promise.all([globalBadges, channelBadges]);
const newObj: badgeObject = {};
parseRawBadges(newObj, rawBadges[0]);
parseRawBadges(newObj, rawBadges[1]);
return Response.json(newObj);
},
"/getEmotes": async () => {
const [bttvglobal, bttvuser, ffzglobal, ffzuser, seventvglobal, seventvuser] = await Promise.all([
fetch("https://api.betterttv.net/3/cached/emotes/global").then(a => a.json() as any),
fetch("https://api.betterttv.net/3/cached/users/twitch/" + streamerId).then(a => a.json() as any),
fetch("https://api.frankerfacez.com/v1/set/global").then(a => a.json() as any),
fetch("https://api.frankerfacez.com/v1/room/id/" + streamerId).then(a => a.json() as any),
fetch("https://7tv.io/v3/emote-sets/global").then(a => a.json() as any),
fetch("https://7tv.io/v3/users/twitch/" + streamerId).then(a => a.json() as any)
]);
const emotes: emoteObject = {};
for (const a of bttvglobal) {
emotes[a.code] = `https://cdn.betterttv.net/emote/${a.id}/3x.${a.imageType}`;
};
for (const a of bttvuser.sharedEmotes) {
emotes[a.code] = `https://cdn.betterttv.net/emote/${a.id}/3x.${a.imageType}`;
};
for (const a of ffzglobal.default_sets) {
for (const b of ffzglobal.sets[a].emoticons) {
emotes[b.name] = `https://cdn.frankerfacez.com/emote/${b.id}/4`;
};
};
for (const a of ffzuser.sets[ffzuser.room.set].emoticons) {
emotes[a.name] = `https://cdn.frankerfacez.com/emote/${a.id}/4`;
}
for (const a of seventvglobal.emotes) {
emotes[a.name] = `https://cdn.7tv.app/emote/${a.id}/4x.avif`;
};
for (const a of seventvuser.emote_set.emotes) {
emotes[a.name] = `https://cdn.7tv.app/emote/${a.id}/4x.avif`;
};
return Response.json(emotes);
}
},
websocket: {
open(_ws) {
sendTwitchEvent({
function: 'serverNotification',
message: 'Sucessfully opened websocket connection'
});
},
message(ws, omessage) {
const message = JSON.parse(omessage.toString());
if (!message.type) return;
switch (message.type) {
case 'subscribe':
if (!message.target) return;
ws.subscribe(message.target);
sendTwitchEvent({
function: 'serverNotification',
message: `Successfully subscribed to all ${message.target} events`
});
break;
};
},
close(ws) {
ws.close();
}
},
development: true,
error(error) {
logger.err(`Error at chatwidget server: ${error}`);
return new Response("Internal Server Error", { status: 500 })
},
});
export async function sendTwitchEvent(event: twitchEventData) {
server.publish('twitch', JSON.stringify(event));
};
import { HelixChatBadgeSet } from "@twurple/api";
function parseRawBadges(returnobj: badgeObject, data: HelixChatBadgeSet[]) {
for (const badge of data) {
if (!returnobj[badge.id]) returnobj[badge.id] = {};
for (const version of badge.versions) {
returnobj[badge.id]![version.id] = version.getImageUrl(4);
};
};
};

View File

@@ -1,5 +1,5 @@
import { eventSub, streamerId } from ".."; import { eventSub, streamerId } from "..";
import { deleteBannedUserMessagesFromChatWidget } from "../chatwidget/message"; import { deleteBannedUserMessagesFromChatWidget } from "../web/chatWidget/message";
eventSub.onChannelBan(streamerId, async msg => { eventSub.onChannelBan(streamerId, async msg => {
deleteBannedUserMessagesFromChatWidget(msg); deleteBannedUserMessagesFromChatWidget(msg);

View File

@@ -1,5 +1,5 @@
import { eventSub, streamerId } from ".."; import { eventSub, streamerId } from "..";
import { deleteMessageFromChatWidget } from "../chatwidget/message"; import { deleteMessageFromChatWidget } from "../web/chatWidget/message";
eventSub.onChannelChatMessageDelete(streamerId, streamerId, async msg => { eventSub.onChannelChatMessageDelete(streamerId, streamerId, async msg => {
deleteMessageFromChatWidget(msg); deleteMessageFromChatWidget(msg);

View File

@@ -1,12 +1,12 @@
import { EventSubChannelChatMessageEvent } from "@twurple/eventsub-base" import { EventSubChannelChatMessageEvent } from "@twurple/eventsub-base"
import { chatterId, streamerId, eventSub, commandPrefix, singleUserMode, streamerUsers } from ".."; import { streamerId, eventSub, commandPrefix, streamerUsers } from "..";
import { User } from "../user"; import { User } from "../user";
import commands, { sendMessage } from "../commands"; import commands, { sendMessage } from "../commands";
import { redis } from "bun"; import { redis } from "bun";
import { isAdmin } from "../lib/admins"; import { isAdmin } from "../lib/admins";
import cheers from "../cheers"; import cheers from "../cheers";
import logger from "../lib/logger"; import logger from "../lib/logger";
import { addMessageToChatWidget } from "../chatwidget/message"; import { addMessageToChatWidget } from "../web/chatWidget/message";
import { isInvuln } from "../lib/invuln"; import { isInvuln } from "../lib/invuln";
logger.info(`Loaded the following commands: ${commands.keys().toArray().join(', ')}`); logger.info(`Loaded the following commands: ${commands.keys().toArray().join(', ')}`);

View File

@@ -33,4 +33,4 @@ streamerUsers.forEach(async id => await Promise.all([addAdmin(id), addInvuln(id)
await import("./events"); await import("./events");
await import("./chatwidget"); await import("./web");

View File

@@ -1,8 +1,8 @@
import { EventSubChannelChatMessageEvent, EventSubChannelChatMessageDeleteEvent, EventSubChannelBanEvent } from "@twurple/eventsub-base"; import { EventSubChannelChatMessageEvent, EventSubChannelChatMessageDeleteEvent, EventSubChannelBanEvent } from "@twurple/eventsub-base";
import { sendTwitchEvent } from "."; import { sendTwitchChatEvent } from "./widgetServerFunctions";
export async function addMessageToChatWidget(msg: EventSubChannelChatMessageEvent) { export async function addMessageToChatWidget(msg: EventSubChannelChatMessageEvent) {
await sendTwitchEvent({ await sendTwitchChatEvent({
function: 'createMessage', function: 'createMessage',
messageParts: msg.messageParts, messageParts: msg.messageParts,
displayName: msg.chatterDisplayName, displayName: msg.chatterDisplayName,
@@ -14,14 +14,14 @@ export async function addMessageToChatWidget(msg: EventSubChannelChatMessageEven
}; };
export async function deleteMessageFromChatWidget(msg: EventSubChannelChatMessageDeleteEvent) { export async function deleteMessageFromChatWidget(msg: EventSubChannelChatMessageDeleteEvent) {
await sendTwitchEvent({ await sendTwitchChatEvent({
function: 'deleteMessage', function: 'deleteMessage',
messageId: msg.messageId messageId: msg.messageId
}) })
}; };
export async function deleteBannedUserMessagesFromChatWidget(msg: EventSubChannelBanEvent) { export async function deleteBannedUserMessagesFromChatWidget(msg: EventSubChannelBanEvent) {
sendTwitchEvent({ sendTwitchChatEvent({
function: 'userBan', function: 'userBan',
chatterId: msg.userId chatterId: msg.userId
}); });

View File

@@ -0,0 +1,74 @@
import { streamerId, chatterApi } from "../..";
type badgeObject = {
[key: string]: {
[key: string]: string;
};
};
type emoteObject = {
[key: string]: string;
};
export async function getBadges() {
const globalBadges = chatterApi.chat.getGlobalBadges();
const channelBadges = chatterApi.chat.getChannelBadges(streamerId);
const rawBadges = await Promise.all([globalBadges, channelBadges]);
const newObj: badgeObject = {};
parseRawBadges(newObj, rawBadges[0]);
parseRawBadges(newObj, rawBadges[1]);
return Response.json(newObj);
};
export async function getExternalEmotes() {
const [bttvglobal, bttvuser, ffzglobal, ffzuser, seventvglobal, seventvuser] = await Promise.all([
fetch("https://api.betterttv.net/3/cached/emotes/global").then(a => a.json() as any),
fetch("https://api.betterttv.net/3/cached/users/twitch/" + streamerId).then(a => a.json() as any),
fetch("https://api.frankerfacez.com/v1/set/global").then(a => a.json() as any),
fetch("https://api.frankerfacez.com/v1/room/id/" + streamerId).then(a => a.json() as any),
fetch("https://7tv.io/v3/emote-sets/global").then(a => a.json() as any),
fetch("https://7tv.io/v3/users/twitch/" + streamerId).then(a => a.json() as any)
]);
const emotes: emoteObject = {};
for (const a of bttvglobal) {
emotes[a.code] = `https://cdn.betterttv.net/emote/${a.id}/3x.${a.imageType}`;
};
for (const a of bttvuser.sharedEmotes) {
emotes[a.code] = `https://cdn.betterttv.net/emote/${a.id}/3x.${a.imageType}`;
};
for (const a of ffzglobal.default_sets) {
for (const b of ffzglobal.sets[a].emoticons) {
emotes[b.name] = `https://cdn.frankerfacez.com/emote/${b.id}/4`;
};
};
for (const a of ffzuser.sets[ffzuser.room.set].emoticons) {
emotes[a.name] = `https://cdn.frankerfacez.com/emote/${a.id}/4`;
}
for (const a of seventvglobal.emotes) {
emotes[a.name] = `https://cdn.7tv.app/emote/${a.id}/4x.avif`;
};
for (const a of seventvuser.emote_set.emotes) {
emotes[a.name] = `https://cdn.7tv.app/emote/${a.id}/4x.avif`;
};
return Response.json(emotes);
}
import { HelixChatBadgeSet } from "@twurple/api";
function parseRawBadges(returnobj: badgeObject, data: HelixChatBadgeSet[]) {
for (const badge of data) {
if (!returnobj[badge.id]) returnobj[badge.id] = {};
for (const version of badge.versions) {
returnobj[badge.id]![version.id] = version.getImageUrl(4);
};
};
};
import server from "..";
import type { twitchEventData } from "./websockettypes";
export async function sendTwitchChatEvent(event: twitchEventData) {
server.publish('twitchchat', JSON.stringify(event));
};

View File

@@ -1,5 +1,47 @@
const badges = await fetch(`http://${location.host}/getBadges`).then(data => data.json()); const popover = document.createElement('div')
const emotes = await fetch(`http://${location.host}/getEmotes`).then(data => data.json()); Object.assign(popover.style, {
position: 'fixed',
top: '20px',
right: '20px',
background: 'rgba(0, 0, 0, 0.85)',
color: 'white',
padding: '10px 20px',
borderRadius: '5px',
fontSize: '9vmin',
zIndex: 9999
});
popover.textContent = 'Loading...'
document.body.appendChild(popover);
const [badges, emotes] = await Promise.all([
fetch(`http://${location.host}/chat/getBadges`).then(data => data.json()),
fetch(`http://${location.host}/chat/getEmotes`).then(data => data.json())
]);
await prefetchImages(Object.values(emotes));
popover.remove();
async function prefetchImages(urls: string[], maxRetries = 3, retryDelay = 500) {
const sleep = (ms: number) => new Promise(res => setTimeout(res, ms));
const loadImage = async (url: string) => {
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
await new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => resolve(true);
img.onerror = () => reject();
img.src = url;
});
} catch (err) {
if (attempt < maxRetries) await sleep(retryDelay);
};
};
};
await Promise.all(urls.map(url => loadImage(url)));
};
import { type createMessageEvent } from '../../websockettypes'; import { type createMessageEvent } from '../../websockettypes';

View File

@@ -8,7 +8,7 @@ const socket = new WebSocket(`ws://${location.host}`);
socket.onopen = () => { socket.onopen = () => {
socket.send(JSON.stringify({ socket.send(JSON.stringify({
type: 'subscribe', type: 'subscribe',
target: 'twitch' target: 'twitchchat'
})); }));
}; };

51
src/web/index.ts Normal file
View File

@@ -0,0 +1,51 @@
import logger from "../lib/logger";
import { getBadges, getExternalEmotes } from "./chatWidget/widgetServerFunctions";
import chatWidget from "./chatWidget/www/index.html";
import { sendTwitchChatEvent } from "./chatWidget/widgetServerFunctions";
const port = Number(process.env.WEB_PORT);
if (isNaN(port)) { logger.enverr("WEB_PORT"); process.exit(1); };
export default Bun.serve({
port,
fetch(request, server) {
if (server.upgrade(request)) return;
return new Response('oops', { status: 500 });
},
routes: {
"/chat": chatWidget,
"/chat/getBadges": getBadges,
"/chat/getEmotes": getExternalEmotes
},
websocket: {
open(_ws) {
sendTwitchChatEvent({
function: 'serverNotification',
message: 'Sucessfully opened websocket connection'
});
},
message(ws, omessage) {
const message = JSON.parse(omessage.toString());
if (!message.type) return;
switch (message.type) {
case 'subscribe':
if (!message.target) return;
ws.subscribe(message.target);
sendTwitchChatEvent({
function: 'serverNotification',
message: `Successfully subscribed to all ${message.target} events`
});
break;
};
},
close(ws) {
ws.close();
}
},
development: true,
error(error) {
logger.err(`Error at chatwidget server: ${error}`);
return new Response("Internal Server Error", { status: 500 })
},
});