diff --git a/.example.env b/.example.env index f68137f..fa92395 100644 --- a/.example.env +++ b/.example.env @@ -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_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 -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/ diff --git a/src/chatwidget/index.ts b/src/chatwidget/index.ts deleted file mode 100644 index 35c3711..0000000 --- a/src/chatwidget/index.ts +++ /dev/null @@ -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); - }; - }; -}; diff --git a/src/events/banned.ts b/src/events/banned.ts index 0bea04b..5e62a1e 100644 --- a/src/events/banned.ts +++ b/src/events/banned.ts @@ -1,5 +1,5 @@ import { eventSub, streamerId } from ".."; -import { deleteBannedUserMessagesFromChatWidget } from "../chatwidget/message"; +import { deleteBannedUserMessagesFromChatWidget } from "../web/chatWidget/message"; eventSub.onChannelBan(streamerId, async msg => { deleteBannedUserMessagesFromChatWidget(msg); diff --git a/src/events/deleteMessage.ts b/src/events/deleteMessage.ts index d98d622..0396787 100644 --- a/src/events/deleteMessage.ts +++ b/src/events/deleteMessage.ts @@ -1,5 +1,5 @@ import { eventSub, streamerId } from ".."; -import { deleteMessageFromChatWidget } from "../chatwidget/message"; +import { deleteMessageFromChatWidget } from "../web/chatWidget/message"; eventSub.onChannelChatMessageDelete(streamerId, streamerId, async msg => { deleteMessageFromChatWidget(msg); diff --git a/src/events/message.ts b/src/events/message.ts index 5bf59a3..94e826b 100644 --- a/src/events/message.ts +++ b/src/events/message.ts @@ -1,12 +1,12 @@ 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 commands, { sendMessage } from "../commands"; import { redis } from "bun"; import { isAdmin } from "../lib/admins"; import cheers from "../cheers"; import logger from "../lib/logger"; -import { addMessageToChatWidget } from "../chatwidget/message"; +import { addMessageToChatWidget } from "../web/chatWidget/message"; import { isInvuln } from "../lib/invuln"; logger.info(`Loaded the following commands: ${commands.keys().toArray().join(', ')}`); diff --git a/src/index.ts b/src/index.ts index 7ea60de..2dfbda7 100644 --- a/src/index.ts +++ b/src/index.ts @@ -33,4 +33,4 @@ streamerUsers.forEach(async id => await Promise.all([addAdmin(id), addInvuln(id) await import("./events"); -await import("./chatwidget"); +await import("./web"); diff --git a/src/chatwidget/message.ts b/src/web/chatWidget/message.ts similarity index 84% rename from src/chatwidget/message.ts rename to src/web/chatWidget/message.ts index a4f402e..c691a76 100644 --- a/src/chatwidget/message.ts +++ b/src/web/chatWidget/message.ts @@ -1,8 +1,8 @@ import { EventSubChannelChatMessageEvent, EventSubChannelChatMessageDeleteEvent, EventSubChannelBanEvent } from "@twurple/eventsub-base"; -import { sendTwitchEvent } from "."; +import { sendTwitchChatEvent } from "./widgetServerFunctions"; export async function addMessageToChatWidget(msg: EventSubChannelChatMessageEvent) { - await sendTwitchEvent({ + await sendTwitchChatEvent({ function: 'createMessage', messageParts: msg.messageParts, displayName: msg.chatterDisplayName, @@ -14,14 +14,14 @@ export async function addMessageToChatWidget(msg: EventSubChannelChatMessageEven }; export async function deleteMessageFromChatWidget(msg: EventSubChannelChatMessageDeleteEvent) { - await sendTwitchEvent({ + await sendTwitchChatEvent({ function: 'deleteMessage', messageId: msg.messageId }) }; export async function deleteBannedUserMessagesFromChatWidget(msg: EventSubChannelBanEvent) { - sendTwitchEvent({ + sendTwitchChatEvent({ function: 'userBan', chatterId: msg.userId }); diff --git a/src/chatwidget/websockettypes.ts b/src/web/chatWidget/websockettypes.ts similarity index 100% rename from src/chatwidget/websockettypes.ts rename to src/web/chatWidget/websockettypes.ts diff --git a/src/web/chatWidget/widgetServerFunctions.ts b/src/web/chatWidget/widgetServerFunctions.ts new file mode 100644 index 0000000..cb040e2 --- /dev/null +++ b/src/web/chatWidget/widgetServerFunctions.ts @@ -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)); +}; diff --git a/src/chatwidget/www/index.html b/src/web/chatWidget/www/index.html similarity index 100% rename from src/chatwidget/www/index.html rename to src/web/chatWidget/www/index.html diff --git a/src/chatwidget/www/src/createMessage.ts b/src/web/chatWidget/www/src/createMessage.ts similarity index 70% rename from src/chatwidget/www/src/createMessage.ts rename to src/web/chatWidget/www/src/createMessage.ts index d60d3c2..ffe2f08 100644 --- a/src/chatwidget/www/src/createMessage.ts +++ b/src/web/chatWidget/www/src/createMessage.ts @@ -1,5 +1,47 @@ -const badges = await fetch(`http://${location.host}/getBadges`).then(data => data.json()); -const emotes = await fetch(`http://${location.host}/getEmotes`).then(data => data.json()); +const popover = document.createElement('div') +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'; diff --git a/src/chatwidget/www/src/main.ts b/src/web/chatWidget/www/src/main.ts similarity index 97% rename from src/chatwidget/www/src/main.ts rename to src/web/chatWidget/www/src/main.ts index 570e150..10b2ec8 100644 --- a/src/chatwidget/www/src/main.ts +++ b/src/web/chatWidget/www/src/main.ts @@ -8,7 +8,7 @@ const socket = new WebSocket(`ws://${location.host}`); socket.onopen = () => { socket.send(JSON.stringify({ type: 'subscribe', - target: 'twitch' + target: 'twitchchat' })); }; diff --git a/src/chatwidget/www/src/style.css b/src/web/chatWidget/www/src/style.css similarity index 100% rename from src/chatwidget/www/src/style.css rename to src/web/chatWidget/www/src/style.css diff --git a/src/chatwidget/www/tsconfig.json b/src/web/chatWidget/www/tsconfig.json similarity index 100% rename from src/chatwidget/www/tsconfig.json rename to src/web/chatWidget/www/tsconfig.json diff --git a/src/web/index.ts b/src/web/index.ts new file mode 100644 index 0000000..7291e0c --- /dev/null +++ b/src/web/index.ts @@ -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 }) + }, +}); +