diff --git a/common/types.ts b/common/types.ts index 7bf8fde..9faf6cc 100644 --- a/common/types.ts +++ b/common/types.ts @@ -1,4 +1,4 @@ -export enum LiteralTypes { +export const enum LiteralTypes { String, Integer, Float, @@ -6,15 +6,18 @@ export enum LiteralTypes { Vector } -type EnumTypeName = string; +export const enum AnnounceType { + DefaultAnnounce, + DefaultSilent, + // e.g. "add signal" + AlwaysAnnounce, + // e.g. "say"/"hint" (because the message itself is the announcement) + AlwaysSilent, +} +type EnumTypeName = string; type ParamType = LiteralTypes | EnumTypeName; -export type Enum = { - name: EnumTypeName; - values: string[]; -}; - export type Parameter = TextParam | NumericParam | BooleanParam | EnumParam | VectorParam; type ParameterBase = { name: string; @@ -59,18 +62,21 @@ export type Redeem = { id: string; title: string; description: string; + args: Parameter[]; + announce?: AnnounceType; + moderated?: boolean; + image: string; price: number; sku: string; - args: Parameter[]; disabled?: boolean; hidden?: boolean; }; export type Config = { version: number; - enums?: Enum[]; - redeems?: Redeem[]; + enums?: { [name: string]: string[] }; + redeems?: { [id: string]: Redeem }; banned?: string[]; message?: string; }; @@ -80,6 +86,7 @@ export type Cart = { id: string; sku: string; args: { [name: string]: any }; + announce: boolean; }; export type IdentifiableCart = Cart & { diff --git a/ebs/package.json b/ebs/package.json index 239458e..360621a 100644 --- a/ebs/package.json +++ b/ebs/package.json @@ -12,6 +12,7 @@ "cors": "^2.8.5", "dotenv": "^16.4.5", "express": "^4.19.2", + "express-ws": "^5.0.2", "jsonpack": "^1.1.5", "jsonwebtoken": "^9.0.2", "mysql2": "^3.10.0", @@ -21,6 +22,7 @@ "@types/body-parser": "^1.19.5", "@types/cors": "^2.8.17", "@types/express": "^4.17.21", + "@types/express-ws": "^3.0.4", "@types/jsonpack": "^1.1.6", "@types/jsonwebtoken": "^9.0.6", "@types/uuid": "^9.0.8", diff --git a/ebs/src/index.ts b/ebs/src/index.ts index ed8b90c..d1dc25f 100644 --- a/ebs/src/index.ts +++ b/ebs/src/index.ts @@ -1,17 +1,17 @@ import { config as dotenv } from "dotenv"; import cors from "cors"; import express from "express"; +import expressWs from "express-ws"; import bodyParser from "body-parser"; -import mysql from "mysql2/promise"; import { privateApiAuth, publicApiAuth } from "./util/middleware"; -import { setupDb } from "./util/db"; +import { initDb } from "./util/db"; dotenv(); const port = 3000; -export const app = express(); -app.use(cors({ origin: "*" })); +export const { app } = expressWs(express()); +app.use(cors({ origin: "*" })) app.use(bodyParser.json()); app.use("/public/*", publicApiAuth); app.use("/private/*", privateApiAuth); @@ -20,31 +20,16 @@ app.get("/", (_, res) => { res.send("YOU ARE TRESPASSING ON PRIVATE PROPERTY YOU HAVE 5 SECONDS TO GET OUT OR I WILL CALL THE POLICE"); }); -export let db: mysql.Connection; - async function main() { - while (true) { - try { - db = await mysql.createConnection({ - host: process.env.MYSQL_HOST, - user: process.env.MYSQL_USER, - password: process.env.MYSQL_PASSWORD, - database: process.env.MYSQL_DATABASE, - }); - break; - } catch { - console.log("Failed to connect to database. Retrying in 5 seconds..."); - await new Promise((resolve) => setTimeout(resolve, 5000)); - } - } - - await setupDb(); + await initDb(); app.listen(port, () => { console.log("Listening on port " + port); require("./modules/config"); require("./modules/transactions"); + require("./modules/game"); + }); } diff --git a/ebs/src/modules/game/connection.ts b/ebs/src/modules/game/connection.ts new file mode 100644 index 0000000..5b1c72e --- /dev/null +++ b/ebs/src/modules/game/connection.ts @@ -0,0 +1,156 @@ +import { Message, MessageType } from "./messages"; +import { ResultMessage, HelloMessage, GameMessage } from "./messages.game"; +import * as ServerWS from "ws"; +import { v4 as uuid } from "uuid"; +import { CommandInvocationSource, RedeemMessage, ServerMessage } from "./messages.server"; +import { Redeem } from "common/types"; + +const VERSION = "0.1.0"; + +type ResultHandler = (result: ResultMessage) => any; + +export class GameConnection { + private handshake: boolean = false; + private socket: ServerWS | null = null; + private resultHandlers: ResultHandler[] = []; + private outstandingRedeems: Map = new Map(); + + public isConnected() { + return this.socket?.readyState == ServerWS.OPEN; + } + public onResult(handler: ResultHandler) { + this.resultHandlers.push(handler); + } + public setSocket(ws: ServerWS | null) { + if (this.isConnected()) { + this.socket!.close(); + } + this.socket = ws; + if (!ws) { + return; + } + ws.on('connection', () => { + this.handshake = false; + }) + ws.on('message', async (message) => { + const msgText = message.toString(); + let msg: GameMessage; + try { + msg = JSON.parse(msgText); + } catch { + console.error("Could not parse message" + msgText); + return; + } + console.log(`Got message ${JSON.stringify(msg)}`); + this.processMessage(msg); + }); + ws.on("close", (code, reason) => { + console.log(`Connection closed with code ${code} and reason ${reason}`); + }) + ws.on("error", (error) => { + console.log(`Connection error ${error}`); + }) + } + public async processMessage(msg: GameMessage) { + switch (msg.messageType) { + case MessageType.Hello: + this.handshake = true; + const reply = { + ...this.makeMessage(MessageType.HelloBack), + allowed: msg.version == VERSION, + } + this.sendMessage(reply); + break; + case MessageType.Ping: + this.sendMessage(this.makeMessage(MessageType.Pong)); + break; + case MessageType.Result: + if (!this.outstandingRedeems.has(msg.guid)) { + console.error(`[${msg.guid}] Redeeming untracked ${msg.guid} (either unpaid or more than once)`); + } + for (const handler of this.resultHandlers) { + handler(msg); + } + this.outstandingRedeems.delete(msg.guid); + break; + case MessageType.IngameStateChanged: + this.logMessage(msg, `${MessageType[MessageType.IngameStateChanged]} stub`); + break; + // case MessageType.CommandAvailabilityChanged: + // await this.updateCommandAvailability(msg); + // break; + default: + console.error(`[${msg.guid}] Unknown message type ${msg.messageType}`); + break; + } + } + // private async updateCommandAvailability(msg: CommandAvailabilityChangedMessage) { + // const config = await getConfig(); + // if (!config) { + // console.error("Can't change command availability, no config"); + // } + // for (const id of msg.becameAvailable) { + // const redeem = config.redeems![id]; + // redeem.disabled = false; + // } + // for (const id of msg.becameUnavailable) { + // const redeem = config.redeems![id]; + // redeem.disabled = true; + // } + // broadcastConfigRefresh(config); + // } + + public sendMessage(msg: ServerMessage) { + if (!this.socket) { + // todo queue unsent messages + console.error(`Tried to send message without a connected socket: ${JSON.stringify(msg)}`); + return; + } + if (!this.handshake) { + console.error(`Tried to send message before handshake was complete: ${JSON.stringify(msg)}`); + return; + } + this.socket.send(JSON.stringify(msg), { binary: false, fin: true }, (err) => { + if (err) + console.error(err); + }); + console.log(`Sent message ${JSON.stringify(msg)}`); + } + public makeMessage(type: MessageType, guid?: string): Message { + return { + messageType: type, + guid: guid ?? uuid(), + timestamp: Date.now() + } + } + public redeem(redeem: Redeem, args: {[name: string]: any}, announce: boolean, transactionId: string) { + if (!transactionId) { + console.error(`Tried to redeem without transaction ID`); + return; + } + + const msg: RedeemMessage = { + ...this.makeMessage(MessageType.Redeem), + guid: transactionId, + source: CommandInvocationSource.Swarm, + command: redeem.id, + title: redeem.title, + announce, + args + } as RedeemMessage; + if (this.outstandingRedeems.has(msg.guid)) { + console.error(`Redeeming ${msg.guid} more than once`); + } + this.outstandingRedeems.set(msg.guid, msg); + + if (!this.isConnected()) { + console.error(`Redeemed without active connection`); + } + + this.sendMessage(msg); + } + + private logMessage(msg: Message, message: string) { + console.log(`[${msg.guid}] ${message}`); + } +} \ No newline at end of file diff --git a/ebs/src/modules/game/index.ts b/ebs/src/modules/game/index.ts new file mode 100644 index 0000000..7c9a8d7 --- /dev/null +++ b/ebs/src/modules/game/index.ts @@ -0,0 +1,61 @@ +import { app } from "../../index"; +import { logToDiscord } from "../../util/logger"; +import { GameConnection } from "./connection"; +import { MessageType } from "./messages"; +import { ResultMessage } from "./messages.game"; +import { CommandInvocationSource, RedeemMessage } from "./messages.server"; + +export let connection: GameConnection = new GameConnection(); + +connection.onResult((res) => { + if (!res.success) { + logToDiscord({ + transactionToken: res.guid, + userIdInsecure: null, + important: false, + fields: [ + { + header: "Redeem did not succeed", + content: res, + }, + ], + }); + } else { + console.log(`[${res.guid}] Redeem succeeded: ${JSON.stringify(res)}`); + } +}) + +app.ws("/private/socket", async (ws, req) => { + connection.setSocket(ws); +}) + +app.post("/private/redeem", async (req, res) => { + //console.log(req.body); + const msg = { + ...connection.makeMessage(MessageType.Redeem), + source: CommandInvocationSource.Dev, + ...req.body, + } as RedeemMessage; + if (!connection.isConnected()) { + res.status(500).send("Not connected"); + return; + } + + connection.sendMessage(msg); + res.status(201).send(JSON.stringify(msg)); +}) + +app.post("/private/setresult", async (req, res) => { + //console.log(req.body); + const msg = { + ...connection.makeMessage(MessageType.Result), + ...req.body, + } as ResultMessage; + if (!connection.isConnected()) { + res.status(500).send("Not connected"); + return; + } + + connection.processMessage(msg); + res.sendStatus(200); +}); \ No newline at end of file diff --git a/ebs/src/modules/game/messages.game.ts b/ebs/src/modules/game/messages.game.ts new file mode 100644 index 0000000..505b3a0 --- /dev/null +++ b/ebs/src/modules/game/messages.game.ts @@ -0,0 +1,48 @@ +import { Message as MessageBase, MessageType } from "./messages"; + +export type GameMessage += HelloMessage + | PingMessage + | LogMessage + | ResultMessage + | IngameStateChangedMessage; + // | CommandAvailabilityChangedMessage; + +type GameMessageBase = MessageBase; // no extra properties +export type HelloMessage = GameMessageBase & { + messageType: MessageType.Hello, + version: string, +} + +export type PingMessage = GameMessageBase & { + messageType: MessageType.Ping +} +export type LogMessage = GameMessageBase & { + messageType: MessageType.Log, + important: boolean, + message: string, +} + +export type ResultMessage = GameMessageBase & { + messageType: MessageType.Result, + success: boolean, + message?: string, +} + +export type IngameStateChangedMessage = GameMessageBase & { + messageType: MessageType.IngameStateChanged, + // if false, commands that need Player.main will be disabled + ingame: boolean, + // if true, commands that depend on ingame time will be queued + paused: boolean, + // inside base or seatruck - disables spawns + indoors: boolean, + // also disables spawns + inWater: boolean, +} + +// export type CommandAvailabilityChangedMessage = GameMessageBase & { +// messageType: MessageType.CommandAvailabilityChanged, +// becameAvailable: string[], +// becameUnavailable: string[], +// } \ No newline at end of file diff --git a/ebs/src/modules/game/messages.server.ts b/ebs/src/modules/game/messages.server.ts new file mode 100644 index 0000000..4e50552 --- /dev/null +++ b/ebs/src/modules/game/messages.server.ts @@ -0,0 +1,28 @@ +import { MessageType, Message, TwitchUser } from "./messages"; + +export type ServerMessage = Message & { + /** User that triggered the message. e.g. for redeems, the user who bought the redeem. */ + user?: TwitchUser +} +export type HelloBackMessage = ServerMessage & { + messageType: MessageType.HelloBack, + allowed: boolean +} + +export type ConsoleInputMessage = ServerMessage & { + messageType: MessageType.ConsoleInput, + input: string +} + +export enum CommandInvocationSource { + Swarm, + Dev +} +export type RedeemMessage = ServerMessage & { + messageType: MessageType.Redeem, + source: CommandInvocationSource, + command: string, + title?: string, + announce: boolean, + args: any +} \ No newline at end of file diff --git a/ebs/src/modules/game/messages.ts b/ebs/src/modules/game/messages.ts new file mode 100644 index 0000000..ab35967 --- /dev/null +++ b/ebs/src/modules/game/messages.ts @@ -0,0 +1,33 @@ +export enum MessageType { + // game to server + Hello, + Ping, + Log, + Result, + IngameStateChanged, + //CommandAvailabilityChanged, + + // server to game + HelloBack, + Pong, + ConsoleInput, + Redeem, +} + +export type Guid = string; +export type UnixTimestampUtc = number; + +export type Message = { + messageType: MessageType, + guid: Guid, + timestamp: UnixTimestampUtc +} + +export type TwitchUser = { + /** Channel id */ + id: number, + /** Twitch username (login name) */ + userName: string, + /** User's chosen display name. */ + displayName: string +} \ No newline at end of file diff --git a/ebs/src/modules/transactions.ts b/ebs/src/modules/transactions.ts index fda507e..079854b 100644 --- a/ebs/src/modules/transactions.ts +++ b/ebs/src/modules/transactions.ts @@ -1,10 +1,12 @@ import { Cart, Transaction } from "common/types"; +//import { AnnounceType } from "common/types"; // esbuild dies import { app } from "../index"; import { parseJWT, verifyJWT } from "../util/jwt"; import { BitsTransactionPayload } from "../types"; import { getConfig } from "./config"; import { getPrepurchase, isReceiptUsed, isUserBanned, registerPrepurchase } from "../util/db"; import { logToDiscord } from "../util/logger"; +import { connection } from "./game"; app.post("/public/prepurchase", async (req, res) => { const cart = req.body as Cart; @@ -155,7 +157,49 @@ app.post("/public/transaction", async (req, res) => { console.log(transaction); console.log(cart); - // TODO: send stuff to mod + const redeem = currentConfig.redeems?.[cart.id]; + if (!redeem) { + logToDiscord({ + transactionToken: transaction.token, + userIdInsecure: req.twitchAuthorization!.user_id!, + important: false, + fields: [ + { + header: "Redeem not found", + content: { + config: currentConfig.version, + cart: cart, + transaction: transaction, + }, + }, + ], + }).then(); + } else { + const redeemAnnounce = redeem.announce || 0; + const [doAnnounce, canChange] = [!(redeemAnnounce & 1), !(redeemAnnounce & 2)]; // funny + // don't allow to change when you shouldn't + if (cart.announce != doAnnounce && !canChange) { + logToDiscord({ + transactionToken: transaction.token, + userIdInsecure: req.twitchAuthorization!.user_id!, + important: false, + fields: [ + { + header: "Invalid announce", + content: { + config: currentConfig.version, + cart: cart, + transaction: transaction, + announceType: redeemAnnounce, + userPicked: cart.announce, + }, + }, + ], + }); + cart.announce = doAnnounce!; + } + connection.redeem(redeem, cart.args, cart.announce, transaction.token); + } res.sendStatus(200); }); diff --git a/ebs/src/util/db.ts b/ebs/src/util/db.ts index 410268c..4646524 100644 --- a/ebs/src/util/db.ts +++ b/ebs/src/util/db.ts @@ -1,8 +1,32 @@ -import { db } from "../index"; import { RowDataPacket } from "mysql2"; +import mysql from "mysql2/promise"; import { IdentifiableCart } from "common/types"; import { v4 as uuid } from "uuid"; +export let db: mysql.Connection; + +export async function initDb() { + if (!process.env.MYSQL_HOST) { + console.warn("No MYSQL_HOST specified (assuming local testing/development), skipping database setup"); + return; + } + while (!db) { + try { + db = await mysql.createConnection({ + host: process.env.MYSQL_HOST, + user: process.env.MYSQL_USER, + password: process.env.MYSQL_PASSWORD, + database: process.env.MYSQL_DATABASE, + }); + } catch { + console.log("Failed to connect to database. Retrying in 5 seconds..."); + await new Promise((resolve) => setTimeout(resolve, 5000)); + } + } + + await setupDb(); +} + export async function setupDb() { await db.query(` CREATE TABLE IF NOT EXISTS transactions ( diff --git a/frontend/www/src/modules/modal.ts b/frontend/www/src/modules/modal.ts index 4baa5cb..3287b7b 100644 --- a/frontend/www/src/modules/modal.ts +++ b/frontend/www/src/modules/modal.ts @@ -1,4 +1,4 @@ -import { BooleanParam, Cart, EnumParam, LiteralTypes, NumericParam, Parameter, Redeem, TextParam, VectorParam } from "common/types"; +import { AnnounceType, BooleanParam, Cart, EnumParam, LiteralTypes, NumericParam, Parameter, Redeem, TextParam, VectorParam } from "common/types"; import { ebsFetch } from "../util/ebs"; import { getConfig } from "../util/config"; import { logToDiscord } from "../util/logger"; @@ -100,7 +100,10 @@ export async function openModal(redeem: Redeem | null) { const config = await getConfig(); - cart = { version: config.version, sku: redeem.sku, id: redeem.id, args: {} }; + const announceType = redeem.announce || AnnounceType.DefaultAnnounce; + const defaultAnnounce = !(announceType & 1); + + cart = { version: config.version, sku: redeem.sku, id: redeem.id, args: {}, announce: defaultAnnounce }; $modalWrapper.style.opacity = "1"; $modalWrapper.style.pointerEvents = "unset"; @@ -268,8 +271,9 @@ async function prePurchase() { return true; } -function addOptionsFields(modal: HTMLElement, redeem: Redeem) { - for (const param of redeem.args || []) +function addOptionsFields(modal: HTMLFormElement, redeem: Redeem) { + addAnnounceCheckbox(modal, redeem.announce); + for (const param of redeem.args || []) { switch (param.type) { case LiteralTypes.String: addText(modal, param); @@ -288,6 +292,7 @@ function addOptionsFields(modal: HTMLElement, redeem: Redeem) { addDropdown(modal, param).then(); break; } + } } function addText(modal: HTMLElement, param: TextParam) { @@ -327,26 +332,25 @@ function addCheckbox(modal: HTMLElement, param: BooleanParam) { if (param.defaultValue !== undefined) { input.checked = param.defaultValue; } + // browser says "required" means "must be checked" + input.required = false; modal.appendChild(field); } async function addDropdown(modal: HTMLElement, param: EnumParam) { - let options: string[] = []; + let options: string[] | undefined = []; - try { - options = (await getConfig()).enums!.find((e) => e.name == param.type)!.values; - } catch { - return; // someone's messing with the config, screw em - } + options = (await getConfig()).enums?.[param.type]; + if (!options) return; // someone's messing with the config, screw em const field = $paramTemplates.dropdown.cloneNode(true) as HTMLSelectElement; const select = field.querySelector("select")!; setupField(field, select, param); - for (const opt of options) { + for (let i = 0; i < options.length; i++) { const option = document.createElement("option"); - option.value = opt; - option.textContent = opt; + option.value = i.toString(); + option.textContent = options[i]; select.appendChild(option); } @@ -399,3 +403,30 @@ function setupField(field: HTMLElement, inputElem: HTMLSelectElement | HTMLInput label.ariaRequired = ""; } } + +const announceParam: BooleanParam = { + name: "_announce", + type: LiteralTypes.Boolean, + title: "Announce", + description: "Whether to announce the redeem on stream", +} + +function addAnnounceCheckbox(modal: HTMLFormElement, announce: AnnounceType | undefined) { + announce = announce || AnnounceType.DefaultAnnounce; + if (announce > AnnounceType.DefaultSilent) { + return; + } + const field = $paramTemplates.toggle.cloneNode(true) as HTMLSelectElement; + const input = field.querySelector("input")!; + setupField(field, input, announceParam); + input.onformdata = (e) => { + e.formData.delete(announceParam.name); + }; + input.onchange = (e) => { + cart!.announce = input.checked; + } + if (announce === AnnounceType.DefaultAnnounce) { + input.checked = true; + } + modal.appendChild(field); +} diff --git a/frontend/www/src/modules/pubsub.ts b/frontend/www/src/modules/pubsub.ts index d010c09..0db39c0 100644 --- a/frontend/www/src/modules/pubsub.ts +++ b/frontend/www/src/modules/pubsub.ts @@ -11,6 +11,7 @@ Twitch.ext.listen("global", async (_t, _c, message) => { switch (pubSubMessage.type) { case "config_refreshed": const config = unpack(pubSubMessage.data); + // console.log(config); await setConfig(postProcessConfig(config)); await renderRedeemButtons(); break; diff --git a/frontend/www/src/modules/redeems.ts b/frontend/www/src/modules/redeems.ts index e7daa3b..7c4e4be 100644 --- a/frontend/www/src/modules/redeems.ts +++ b/frontend/www/src/modules/redeems.ts @@ -13,7 +13,7 @@ export async function renderRedeemButtons() { $redeemContainer.innerHTML = `

Loading content...

`; const config = await getConfig(); - const redeems = config.redeems; + const redeems = Object.entries(config.redeems || {}); $redeemContainer.innerHTML = ""; @@ -22,9 +22,9 @@ export async function renderRedeemButtons() { if (config.message) $mainContainer[0].insertAdjacentHTML("afterbegin", `
${config.message}
`); - if (redeems?.length === 0) $redeemContainer.innerHTML = `

No content is available.

`; + if (redeems.length === 0) $redeemContainer.innerHTML = `

No content is available.

`; - for (const redeem of redeems || []) { + for (const [id, redeem] of redeems) { if (redeem.hidden) continue; const item = document.createElement("button"); diff --git a/frontend/www/src/util/config.ts b/frontend/www/src/util/config.ts index 1bb66c1..cbdaacc 100644 --- a/frontend/www/src/util/config.ts +++ b/frontend/www/src/util/config.ts @@ -7,8 +7,8 @@ export function postProcessConfig(config: Config): Config { if (config.banned && config.banned.includes(Twitch.ext.viewer.id!)) { return { version: -1, - redeems: [], - enums: [], + redeems: {}, + enums: {}, banned: [Twitch.ext.viewer.id!], message: "You are banned from using this extension", } satisfies Config; @@ -23,8 +23,8 @@ async function fetchConfig() { if (!response.ok) { return { version: -1, - redeems: [], - enums: [], + redeems: {}, + enums: {}, banned: [], message: `An error occurred while fetching the config\n${response.status} ${response.statusText} - ${await response.text()}`, } satisfies Config;