From 4b0cf034bb2135e3c99dae74d54031a071724d0e Mon Sep 17 00:00:00 2001 From: pleb Date: Sat, 11 Apr 2026 16:13:45 -0700 Subject: [PATCH] Use a deno runtime to read the bs+ bsr queue file (Database.json) --- .gitignore | 4 +- AGENTS.md | 9 ++- README.md | 23 +++++- chat-request-database.path.example | 1 + deno.json | 6 ++ deno.lock | 66 ++++++++++++++++ docs/ADR.md | 7 +- docs/testing.md | 24 +++--- index.css | 99 ++++++++++++++++++++++- index.html | 18 +++-- index.js | 123 ++++++++++++++++++++++++++++- serve.ts | 72 +++++++++++++++++ types.d.ts | 14 ++++ 13 files changed, 432 insertions(+), 34 deletions(-) create mode 100644 chat-request-database.path.example create mode 100644 deno.json create mode 100644 deno.lock create mode 100644 serve.ts diff --git a/.gitignore b/.gitignore index 745e5c7..de9ad49 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,3 @@ -fonts/rajdhani-fontfacekit/ \ No newline at end of file +fonts/rajdhani-fontfacekit/ +ChatRequest.json +chat-request-database.path \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md index 9f3c10c..3e47283 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,16 +1,21 @@ # AGENTS.md -Static OBS/browser overlay that reads Beat Saber Plus Song Overlay events over WebSocket (`ws://localhost:2947/socket`). +OBS/browser overlay that reads Beat Saber Plus Song Overlay events over WebSocket (`ws://localhost:2947/socket`). Serve the folder with **`deno task serve`** (see [`serve.ts`](serve.ts)) so the request-queue JSON loads from the same origin; configure the BS+ database path via `CHAT_REQUEST_DATABASE` or `chat-request-database.path`. + +## Preference: HTTP only, no `file://` + +Do **not** add or maintain code paths for opening the overlay as **`file://`**. The client assumes **`http:` / `https:`** for fetching JSON (cache-busted `fetch`). Do not reintroduce XHR/`file:` fallbacks or docs that suggest local file URLs—one supported way: serve with Deno (or another HTTP server) per [`README.md`](README.md). ## Files of interest - [`index.html`](index.html) — Page shell: markup for map info, time bar, score, settings dialog; pulls `index.css` and `index.js`. - [`index.js`](index.js) — Connects to BS+ WebSocket, parses JSON events (`gameState`, `mapInfo`, `pause`, `resume`, `score`), updates DOM; optional BeatSaver API fetch for custom maps; reads/writes settings from URL hash. - [`index.css`](index.css) — Layout, theming, visibility toggles driven by body classes and CSS variables from settings. +- [`serve.ts`](serve.ts) — Deno static server + optional mapping of `ChatRequest.json` / `database.json` to the real BS+ `Database.json` path. - [`types.d.ts`](types.d.ts) — JSDoc typedefs for BS+ payloads and events (editor/IDE hints only). - [`jsconfig.json`](jsconfig.json) — JS project roots/path so editors resolve modules and `types.d.ts`. - [`images/`](images/) — Cover fallback (`unknown.svg`), characteristic icons under `images/characteristic/` (filenames match BS+ characteristic strings). -- [`README.md`](README.md) — User-facing usage (hosted URL, OBS, local `file://`, BS+ module). +- [`README.md`](README.md) — User-facing usage (Deno, OBS URL, BS+ module). - [`dprint.json`](dprint.json) — Formatter config for the repo. - [`.editorconfig`](.editorconfig) — Basic indent/charset rules for editors. diff --git a/README.md b/README.md index bc51edc..9504ba8 100644 --- a/README.md +++ b/README.md @@ -7,8 +7,25 @@ Requires [BeatSaberPlus](https://github.com/hardcpp/BeatSaberPlus) ![](images/screenshots/preview.png) +## Setup + +Install [Deno](https://docs.deno.com/runtime/getting_started/installation/) and serve the overlay over HTTP (see below). + +The server must know where Beat Saber Plus stores **`ChatRequest/Database.json`**. It then serves that file as `ChatRequest.json` and `database.json` (same data) over HTTP—no symlink. + +**Easiest:** copy `chat-request-database.path.example` to **`chat-request-database.path`** in this repo and put **one line**: the absolute path to `Database.json` (gitignored, so it stays on your machine). + +**Or** set the environment variable (overrides the path file): + +```powershell +$env:CHAT_REQUEST_DATABASE = "C:\Users\pleb\BSManager\BSInstances\1.40.8\UserData\BeatSaberPlus\ChatRequest\Database.json" +deno task serve +``` + +Then open **`http://127.0.0.1:8080/index.html`** (use the same host and port the terminal prints). Set `PORT` if needed. In OBS, use that URL for the browser source. + +If neither the path file nor `CHAT_REQUEST_DATABASE` is set, the overlay only finds a queue if you place a `ChatRequest.json` copy in the repo folder. + ### Usage -Clone the repo to use the overlay locally. - -Note: in OBS browser source, use URL `file:///C:/path-to-overlay.../index.html` instead of "Local file" so that URL parameters work. +Clone the repo and run `deno task serve` as above. diff --git a/chat-request-database.path.example b/chat-request-database.path.example new file mode 100644 index 0000000..d2e67ab --- /dev/null +++ b/chat-request-database.path.example @@ -0,0 +1 @@ +C:\path\to\UserData\BeatSaberPlus\ChatRequest\Database.json diff --git a/deno.json b/deno.json new file mode 100644 index 0000000..0de2691 --- /dev/null +++ b/deno.json @@ -0,0 +1,6 @@ +{ + "tasks": { + "serve": "deno run --allow-net --allow-read --allow-env serve.ts", + "dev": "deno run --watch --allow-net --allow-read --allow-env serve.ts" + } +} diff --git a/deno.lock b/deno.lock new file mode 100644 index 0000000..a119795 --- /dev/null +++ b/deno.lock @@ -0,0 +1,66 @@ +{ + "version": "5", + "specifiers": { + "jsr:@std/cli@^1.0.28": "1.0.28", + "jsr:@std/encoding@^1.0.10": "1.0.10", + "jsr:@std/fmt@^1.0.9": "1.0.9", + "jsr:@std/fs@^1.0.23": "1.0.23", + "jsr:@std/html@^1.0.5": "1.0.5", + "jsr:@std/http@*": "1.0.25", + "jsr:@std/internal@^1.0.12": "1.0.12", + "jsr:@std/media-types@^1.1.0": "1.1.0", + "jsr:@std/net@^1.0.6": "1.0.6", + "jsr:@std/path@*": "1.1.4", + "jsr:@std/path@^1.1.4": "1.1.4", + "jsr:@std/streams@^1.0.17": "1.0.17" + }, + "jsr": { + "@std/cli@1.0.28": { + "integrity": "74ef9b976db59ca6b23a5283469c9072be6276853807a83ec6c7ce412135c70a" + }, + "@std/encoding@1.0.10": { + "integrity": "8783c6384a2d13abd5e9e87a7ae0520a30e9f56aeeaa3bdf910a3eaaf5c811a1" + }, + "@std/fmt@1.0.9": { + "integrity": "2487343e8899fb2be5d0e3d35013e54477ada198854e52dd05ed0422eddcabe0" + }, + "@std/fs@1.0.23": { + "integrity": "3ecbae4ce4fee03b180fa710caff36bb5adb66631c46a6460aaad49515565a37" + }, + "@std/html@1.0.5": { + "integrity": "4e2d693f474cae8c16a920fa5e15a3b72267b94b84667f11a50c6dd1cb18d35e" + }, + "@std/http@1.0.25": { + "integrity": "577b4252290af1097132812b339fffdd55fb0f4aeb98ff11bdbf67998aa17193", + "dependencies": [ + "jsr:@std/cli", + "jsr:@std/encoding", + "jsr:@std/fmt", + "jsr:@std/fs", + "jsr:@std/html", + "jsr:@std/media-types", + "jsr:@std/net", + "jsr:@std/path@^1.1.4", + "jsr:@std/streams" + ] + }, + "@std/internal@1.0.12": { + "integrity": "972a634fd5bc34b242024402972cd5143eac68d8dffaca5eaa4dba30ce17b027" + }, + "@std/media-types@1.1.0": { + "integrity": "c9d093f0c05c3512932b330e3cc1fe1d627b301db33a4c2c2185c02471d6eaa4" + }, + "@std/net@1.0.6": { + "integrity": "110735f93e95bb9feb95790a8b1d1bf69ec0dc74f3f97a00a76ea5efea25500c" + }, + "@std/path@1.1.4": { + "integrity": "1d2d43f39efb1b42f0b1882a25486647cb851481862dc7313390b2bb044314b5", + "dependencies": [ + "jsr:@std/internal" + ] + }, + "@std/streams@1.0.17": { + "integrity": "7859f3d9deed83cf4b41f19223d4a67661b3d3819e9fc117698f493bf5992140" + } + } +} diff --git a/docs/ADR.md b/docs/ADR.md index f5430fd..f200615 100644 --- a/docs/ADR.md +++ b/docs/ADR.md @@ -2,15 +2,14 @@ ## Static web app with typed JavaScript -The overlay loads inside OBS from a URL or `file://` path, with no install step for streamers. +The overlay is plain HTML/CSS/JS with no bundler: run it locally with **`deno task serve`** or host the files on any static server. OBS uses an **http(s) URL** to the page. Consequences: Ease of use -- Adding or updating the overlay is **copy or point OBS at `index.html`** (hosted or local); nothing to build before use. -- We avoid a toolchain that would complicate “drop this folder in” or quick Netlify-style hosting. +- Adding or updating the overlay is **copy files or point OBS at a URL**; nothing to build before use beyond optional Deno for local serving and the chat-request database path. - Type safety is **optional IDE assistance**, not enforced at publish time (there is no `tsc` in CI by default). -This static, dependency-free shape exists **because** it stays easy to wire up as an OBS Browser Source while keeping the codebase maintainable through JSDoc and `types.d.ts`. +This dependency-free client shape stays easy to wire as an OBS Browser Source while keeping the codebase maintainable through JSDoc and `types.d.ts`. ## BeatSaberPlus diff --git a/docs/testing.md b/docs/testing.md index dbe2937..e1a35be 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -1,39 +1,37 @@ # Testing in a browser -The overlay is a static page. You can exercise the UI and wiring without OBS by opening it in any Chromium-based browser (Chrome, Edge) or Firefox. +Run **`deno task serve`** from the repo (see [README](../README.md)), then open the overlay in Chromium (Chrome, Edge) or Firefox. ## Open the page -Use a **`file://` URL** (same idea as OBS): absolute path to `index.html`, forward slashes. This clone: +Use the URL the server prints, for example: -`file:///C:/Users/example/ops/BeatSaber-Overlay/index.html?scale=1.5` +`http://127.0.0.1:8080/index.html?scale=1.5` -Paste that into the address bar, or drag `index.html` into a browser window. +Settings live in the **URL fragment** (after `#`). Put query parameters **before** the hash if you use both: `index.html?debug=1#…` -Settings are stored in the **URL fragment** (after `#`). Using a full `file://` URL (not only picking “Open file” in a way that strips the hash) keeps hash-based settings working, same idea as in the [README](../README.md). +## Preview the song overlay (no Beat Saber) -## Debug mode (no Beat Saber) +**Click anywhere** on the page (outside the settings dialog) to toggle **preview** mode. The song overlay appears with the built-in placeholder labels so you can check layout, toggles, scale, and fade without a game connection. -Without the game, the overlay normally hides outside **Playing** (and during BeatSaver loading). Add a **query parameter** so it stays visible for layout or WebSocket checks: +## Request list simulation -- Enable: `?debug=1` -- Example: `file:///C:/Users/example/ops/BeatSaber-Overlay/index.html?debug=1` +Enable **Debug** in the settings dialog (or add **`?debug=1`** to the URL). The song requests list then uses the **`history`** array from the JSON instead of **`queue`**, so you can see how entries look with the same shape as real data (`key`, `rqn`, `npr`, etc.). The header title is unchanged. -Put `?debug` **before** the `#` hash if you use both: `index.html?debug=1#…` +The Deno server exposes Beat Saber Plus data as **`ChatRequest.json`** and **`database.json`** (same file). With debug, the page tries **`ChatRequest.json`** first, then **`database.json`**. To load a different filename, add **`?requests=yourfile.json`**. ## What you should see -- The overlay layout with placeholder labels until live data arrives. - **Developer tools → Console:** log lines such as `Connecting to ws://localhost:2947/socket` on load. If [Beat Saber Plus](https://github.com/hardcpp/BeatSaberPlus) is **not** running with the Song Overlay module listening on that port, the socket will close and the script **retries every 10 seconds**—that is expected. - **With Beat Saber running** and BS+ Song Overlay enabled: you should see `Connection open.` when the WebSocket succeeds, and map info, time, and score update while you play. ## Quick UI checks without the game -- Click the page (outside the settings dialog) to toggle the **preview** / settings visibility. +- **Click** the page (outside the settings dialog) to toggle **preview** and open the settings strip. - Change checkboxes and values in the dialog; the **URL hash** should update and layout should reflect toggles and scale. ## Live data path -End-to-end testing needs the same pieces as streaming: **Beat Saber**, **Beat Saber Plus** with the **Song Overlay** module active, so `ws://localhost:2947/socket` accepts connections. The browser page only connects to that local WebSocket; it does not start the server. +End-to-end testing needs the same pieces as streaming: **Beat Saber**, **Beat Saber Plus** with the **Song Overlay** module active, so `ws://localhost:2947/socket` accepts connections. The browser page only connects to that local WebSocket; it does not start the game server. For custom maps, the overlay may request **BeatSaver** over HTTPS; use **Network** in devtools if those requests fail (offline, blocked, or API errors). diff --git a/index.css b/index.css index 0df1aff..422bc0c 100644 --- a/index.css +++ b/index.css @@ -36,15 +36,106 @@ body { background: rgba(0, 0, 0, 0); color: white; filter: drop-shadow(0 0 0.1rem black) drop-shadow(0 0 0.1rem black); +} + +#overlayStack { + position: relative; + flex: 1 1 auto; + min-height: 0; + width: 100%; + display: flex; + flex-direction: column; +} + +#songOverlay { + flex: 1; + display: flex; + flex-direction: column; + gap: 0.5rem; + min-height: 0; + opacity: 1; transition-property: opacity; transition-timing-function: ease-out; transition-duration: calc(var(--fade, 300) * 1ms); } -/* Production: hide when not Playing (or loading map meta). Debug: ?debug=1 (see index.html). */ -html:not(.debug) body.loading, -html:not(.debug) body:not([data-game-state="Playing"], .preview) { +#requestOverlay { + position: absolute; + left: 0; + right: 0; + top: 0; + max-height: 100%; + overflow: hidden; + display: flex; + flex-direction: column; + gap: 0.45rem; + white-space: normal; + opacity: 1; + transition-property: opacity; + transition-timing-function: ease-out; + transition-duration: calc(var(--fade, 300) * 1ms); +} + +/* Song panel: hide when not Playing (or loading map meta). Use body.preview to show placeholders (click). */ +body.loading #songOverlay, +body:not([data-game-state="Playing"], .preview) #songOverlay { opacity: 0; + pointer-events: none; +} + +/* Request queue: show when idle (Menu); hide while Playing, preview, or loading map meta */ +body[data-game-state="Playing"] #requestOverlay, +body.preview #requestOverlay, +body.loading #requestOverlay { + opacity: 0; + pointer-events: none; +} + +#requestHeader { + font-size: 2.2rem; + font-weight: 700; + line-height: 1.1; +} + +#requestList { + margin: 0; + padding-left: 2.2rem; + font-size: 1.5rem; + line-height: 1.25; +} + +#requestList:empty { + display: none; +} + +#requestList .request-item + .request-item { + margin-top: 0.35rem; +} + +#requestEmpty { + font-size: 1.45rem; + opacity: 0.88; +} + +#requestOverlay.has-items #requestEmpty { + display: none; +} + +.request-item { + display: list-item; +} + +.request-title { + font-weight: 700; +} + +.request-meta { + opacity: 0.92; + font-size: 1.45rem; +} + +.request-meta::before { + content: "· "; } span { @@ -212,7 +303,7 @@ body:not(.bsr) #bsrKey { display: none; } -body.right > .row { +body.right #songOverlay > .row { flex-direction: row-reverse; } diff --git a/index.html b/index.html index 1d7f4a6..88ce702 100644 --- a/index.html +++ b/index.html @@ -4,12 +4,10 @@ BS Overlay - - + +
+
@@ -39,6 +37,13 @@ 96.9 7
+
+
+
+
Song requests
+
    +
    No pending requests
    +
    @@ -64,9 +69,10 @@ +
    About - Source code + This was forked from Iza's overlay diff --git a/index.js b/index.js index 02efab7..c5bba69 100644 --- a/index.js +++ b/index.js @@ -172,6 +172,7 @@ const settings = { time: true, score: true, bsr: false, + debug: false, right: false, bottom: true, scale: 1, @@ -206,12 +207,13 @@ document.head.appendChild(style); // Settings UI -for (const key of ["cover", "mapInfo", "time", "score", "bsr"]) { +for (const key of ["cover", "mapInfo", "time", "score", "bsr", "debug"]) { const input = document.getElementById(`${key}Input`); input.checked = settings[key]; input.oninput = () => { settings[key] = input.checked; saveSettings(); + if (key === "debug") loadRequestQueue(); }; } @@ -238,3 +240,122 @@ fade.oninput = () => { document.documentElement.onclick = e => document.body.classList.toggle("preview"); document.getElementById("settings").onclick = e => e.stopPropagation(); + +// Song request queue (JSON from same origin as page; poll). See docs/testing.md for ?requests= + +const MAX_REQUESTS = 10; +const REQUEST_POLL_MS = 5000; +const requestListEl = document.getElementById("requestList"); +const requestOverlayEl = document.getElementById("requestOverlay"); +const requestEmptyEl = document.getElementById("requestEmpty"); +const requestTitleCache = new Map(); + +function useRequestHistorySim() { + return settings.debug || new URLSearchParams(location.search).get("debug") === "1"; +} + +/** @returns {string[]} */ +function requestJsonFilenames() { + const explicit = new URLSearchParams(location.search).get("requests"); + if (explicit) return [explicit]; + if (useRequestHistorySim()) return ["ChatRequest.json", "database.json"]; + return ["ChatRequest.json"]; +} + +/** @param {string} fileName */ +function loadJsonNextToPage(fileName) { + const base = new URL(fileName, location.href); + if (base.protocol !== "http:" && base.protocol !== "https:") { + throw new Error("not-http"); + } + const busted = new URL(base.href); + busted.searchParams.set("t", String(Date.now())); + return fetch(busted.href, { cache: "no-store" }).then(res => { + if (!res.ok) throw new Error(String(res.status)); + return res.json(); + }); +} + +async function loadRequestPayload() { + let lastErr; + for (const name of requestJsonFilenames()) { + try { + return await loadJsonNextToPage(name); + } catch (e) { + lastErr = e; + } + } + throw lastErr instanceof Error ? lastErr : new Error(String(lastErr)); +} + +/** @param {ChatRequestEntry} item */ +function requesterLine(item) { + const parts = [item.npr, item.rqn].filter(Boolean); + return parts.length ? parts.join(" ") : item.rqn || ""; +} + +/** @param {string} key @param {HTMLElement} titleEl */ +async function enrichRequestTitle(key, titleEl) { + if (requestTitleCache.has(key)) { + titleEl.textContent = requestTitleCache.get(key); + return; + } + try { + const response = await fetch(`https://api.beatsaver.com/maps/id/${encodeURIComponent(key)}`); + if (!response.ok) return; + const map = await response.json(); + const name = map.metadata?.songName ?? map.name; + if (name && typeof name === "string") { + requestTitleCache.set(key, name); + titleEl.textContent = name; + } + } catch { + // keep !bsr placeholder + } +} + +/** @param {ChatRequestEntry[]} items */ +function renderRequestList(items) { + requestListEl.replaceChildren(); + requestOverlayEl.classList.toggle("has-items", items.length > 0); + for (const item of items) { + const li = document.createElement("li"); + li.className = "request-item"; + const titleEl = document.createElement("span"); + titleEl.className = "request-title"; + titleEl.textContent = `!bsr ${item.key}`; + li.appendChild(titleEl); + const who = requesterLine(item); + if (who) { + const meta = document.createElement("span"); + meta.className = "request-meta"; + meta.textContent = who; + li.appendChild(meta); + } + requestListEl.appendChild(li); + enrichRequestTitle(item.key, titleEl); + } +} + +async function loadRequestQueue() { + try { + /** @type {ChatRequestPayload} */ + const data = await loadRequestPayload(); + if (requestEmptyEl) { + requestEmptyEl.textContent = "No pending requests"; + requestOverlayEl.classList.remove("request-load-failed"); + } + const raw = useRequestHistorySim() ? data.history ?? [] : data.queue ?? []; + const items = raw.slice(0, MAX_REQUESTS); + renderRequestList(items); + } catch { + if (requestEmptyEl) { + requestEmptyEl.textContent = "whupsy, database file missing"; + requestOverlayEl.classList.add("request-load-failed"); + } + renderRequestList([]); + } +} + +loadRequestQueue(); +window.setInterval(loadRequestQueue, REQUEST_POLL_MS); diff --git a/serve.ts b/serve.ts new file mode 100644 index 0000000..05d5f5d --- /dev/null +++ b/serve.ts @@ -0,0 +1,72 @@ +import { join } from "jsr:@std/path"; +import { serveDir } from "jsr:@std/http/file-server"; + +const root = import.meta.dirname ?? "."; +const port = Number(Deno.env.get("PORT") ?? "8080"); + +function trimPathLine(line: string): string { + let s = line.trim(); + if (!s || s.startsWith("#")) return ""; + if ((s.startsWith('"') && s.endsWith('"')) || (s.startsWith("'") && s.endsWith("'"))) { + s = s.slice(1, -1); + } + return s.trim(); +} + +/** Optional one-line file next to this script: absolute path to `ChatRequest/Database.json`. */ +function readOptionalPathFile(): string | undefined { + try { + const pathFile = join(root, "chat-request-database.path"); + const raw = Deno.readTextFileSync(pathFile); + for (const line of raw.split(/\r?\n/)) { + const p = trimPathLine(line); + if (p) return p; + } + } catch { + // missing or unreadable + } + return undefined; +} + +const chatRequestDatabase = + Deno.env.get("CHAT_REQUEST_DATABASE")?.trim() || readOptionalPathFile(); + +function isChatRequestFilename(pathname: string): boolean { + const base = pathname.split("/").pop() ?? ""; + return base === "ChatRequest.json" || base === "database.json"; +} + +Deno.serve({ port, hostname: "127.0.0.1" }, async (req) => { + const url = new URL(req.url); + if (req.method === "GET" && chatRequestDatabase && isChatRequestFilename(url.pathname)) { + try { + let text = await Deno.readTextFile(chatRequestDatabase); + if (text.charCodeAt(0) === 0xfeff) text = text.slice(1); + return new Response(text, { + headers: { + "Content-Type": "application/json; charset=utf-8", + "Cache-Control": "no-store", + }, + }); + } catch (e) { + if (e instanceof Deno.errors.NotFound) { + console.error(`CHAT_REQUEST_DATABASE not found: ${chatRequestDatabase}`); + return new Response(null, { status: 404 }); + } + const msg = e instanceof Error ? e.message : String(e); + console.error(`CHAT_REQUEST_DATABASE read error (${chatRequestDatabase}): ${msg}`); + return new Response(`Failed to read CHAT_REQUEST_DATABASE: ${msg}\n`, { status: 500 }); + } + } + return serveDir(req, { fsRoot: root, showDirListing: false }); +}); + +console.log(`Overlay: http://127.0.0.1:${port}/index.html`); +if (chatRequestDatabase) { + console.log(`Chat request database file: ${chatRequestDatabase}`); +} else { + console.warn( + "No database path: set CHAT_REQUEST_DATABASE or create chat-request-database.path (see README). " + + "Otherwise /ChatRequest.json is only served from this folder if the file exists.", + ); +} diff --git a/types.d.ts b/types.d.ts index 6ea3674..cd2099a 100644 --- a/types.d.ts +++ b/types.d.ts @@ -64,6 +64,20 @@ type BeatSaberPlusEvent = HandshakeEvent | GameStateEvent | ResumeEvent | PauseE type MapInfo = MapInfoChangedEvent["mapInfoChanged"]; type Score = ScoreEvent["scoreEvent"]; +/** Chat request / queue JSON (e.g. ChatRequest.json beside the overlay) */ +interface ChatRequestEntry { + key: string; + rqt: number; + rqn: string; + npr: string; + msg: string; +} + +interface ChatRequestPayload { + queue: ChatRequestEntry[]; + history: ChatRequestEntry[]; +} + interface Document { // Assume non-null getElementById(elementId: `${string}Img`): HTMLImageElement;