From 41deee807a7b90059e32a6b5765daeb96ef3d9ba Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sun, 22 Feb 2026 18:25:15 -0800 Subject: [PATCH] fossabot setup --- frontend.css | 13 ++++ frontend.tsx | 62 ++--------------- server.ts | 184 ++++++++++++++++++++++++++++++++++++++++++++------- 3 files changed, 179 insertions(+), 80 deletions(-) diff --git a/frontend.css b/frontend.css index 9ec4827..e78a5a4 100644 --- a/frontend.css +++ b/frontend.css @@ -138,6 +138,19 @@ body { color: var(--text-dim); } +.vote-hint { + margin: -10px 0 22px; + font-family: var(--mono); + font-size: 11px; + letter-spacing: 0.5px; + text-transform: uppercase; + color: var(--text-muted); +} + +.vote-hint strong { + color: var(--text); +} + /* ── Prompt ───────────────────────────────────────────────────── */ .prompt { diff --git a/frontend.tsx b/frontend.tsx index 99445ee..9e112ab 100644 --- a/frontend.tsx +++ b/frontend.tsx @@ -53,8 +53,7 @@ type ViewerCountMessage = { type: "viewerCount"; viewerCount: number; }; -type VotedAckMessage = { type: "votedAck"; votedFor: "A" | "B" }; -type ServerMessage = StateMessage | ViewerCountMessage | VotedAckMessage; +type ServerMessage = StateMessage | ViewerCountMessage; // ── Model colors & logos ───────────────────────────────────────────────────── @@ -161,9 +160,6 @@ function ContestantCard({ voters, viewerVotes, totalViewerVotes, - votable, - onVote, - isMyVote, }: { task: TaskInfo; voteCount: number; @@ -173,9 +169,6 @@ function ContestantCard({ voters: VoteInfo[]; viewerVotes?: number; totalViewerVotes?: number; - votable?: boolean; - onVote?: () => void; - isMyVote?: boolean; }) { const color = getColor(task.model.name); const pct = totalVotes > 0 ? Math.round((voteCount / totalVotes) * 100) : 0; @@ -186,16 +179,11 @@ function ContestantCard({ return (
{ if (e.key === "Enter" || e.key === " ") onVote?.(); } : undefined} >
- {isMyVote && !isWinner && YOUR PICK} {isWinner && WIN}
@@ -280,14 +268,10 @@ function ContestantCard({ function Arena({ round, total, - myVote, - onVote, viewerVotingSecondsLeft, }: { round: RoundState; total: number | null; - myVote: "A" | "B" | null; - onVote: (side: "A" | "B") => void; viewerVotingSecondsLeft: number; }) { const [contA, contB] = round.contestants; @@ -305,12 +289,6 @@ function Arena({ const votersB = round.votes.filter((v) => v.votedFor?.name === contB.name); const totalViewerVotes = (round.viewerVotesA ?? 0) + (round.viewerVotesB ?? 0); - const canVote = - round.phase === "voting" && - viewerVotingSecondsLeft > 0 && - round.answerTasks[0].finishedAt && - round.answerTasks[1].finishedAt; - const showCountdown = round.phase === "voting" && viewerVotingSecondsLeft > 0; const phaseText = @@ -336,6 +314,11 @@ function Arena({ )}
+ {showCountdown && ( +
+ Vote in Twitch chat: 1 for left, 2 for right. +
+ )} @@ -350,9 +333,6 @@ function Arena({ voters={votersA} viewerVotes={round.viewerVotesA} totalViewerVotes={totalViewerVotes} - votable={!!canVote} - onVote={() => onVote("A")} - isMyVote={myVote === "A"} /> onVote("B")} - isMyVote={myVote === "B"} /> )} @@ -490,19 +467,7 @@ function App() { const [totalRounds, setTotalRounds] = useState(null); const [viewerCount, setViewerCount] = useState(0); const [connected, setConnected] = useState(false); - const [myVote, setMyVote] = useState<"A" | "B" | null>(null); - const [votedRound, setVotedRound] = useState(null); const [viewerVotingSecondsLeft, setViewerVotingSecondsLeft] = useState(0); - const wsRef = React.useRef(null); - - // Reset vote when round changes - useEffect(() => { - const currentRound = state?.active?.num ?? null; - if (currentRound !== null && currentRound !== votedRound) { - setMyVote(null); - setVotedRound(null); - } - }, [state?.active?.num, votedRound]); // Countdown timer for viewer voting useEffect(() => { @@ -530,11 +495,9 @@ function App() { let knownVersion: string | null = null; function connect() { ws = new WebSocket(wsUrl); - wsRef.current = ws; ws.onopen = () => setConnected(true); ws.onclose = () => { setConnected(false); - wsRef.current = null; reconnectTimer = setTimeout(connect, 2000); }; ws.onmessage = (e) => { @@ -549,8 +512,6 @@ function App() { setViewerCount(msg.viewerCount); } else if (msg.type === "viewerCount") { setViewerCount(msg.viewerCount); - } else if (msg.type === "votedAck") { - setMyVote(msg.votedFor); } }; } @@ -562,13 +523,6 @@ function App() { }; }, []); - const handleVote = (side: "A" | "B") => { - if (myVote === side || !wsRef.current) return; - wsRef.current.send(JSON.stringify({ type: "vote", votedFor: side })); - setMyVote(side); - setVotedRound(state?.active?.num ?? null); - }; - if (!connected || !state) return ; const isNextPrompting = @@ -606,8 +560,6 @@ function App() { ) : ( diff --git a/server.ts b/server.ts index 70f0440..395fc63 100644 --- a/server.ts +++ b/server.ts @@ -90,6 +90,14 @@ const MAX_HISTORY_CACHE_KEYS = parsePositiveInt( process.env.MAX_HISTORY_CACHE_KEYS, 500, ); +const FOSSABOT_CHANNEL_LOGIN = ( + process.env.FOSSABOT_CHANNEL_LOGIN ?? "quipslop" +).trim().toLowerCase(); +const FOSSABOT_VOTE_SECRET = process.env.FOSSABOT_VOTE_SECRET ?? ""; +const FOSSABOT_VALIDATE_TIMEOUT_MS = parsePositiveInt( + process.env.FOSSABOT_VALIDATE_TIMEOUT_MS, + 1_500, +); const ADMIN_COOKIE = "quipslop_admin"; const ADMIN_COOKIE_MAX_AGE_SECONDS = 60 * 60 * 24 * 30; @@ -249,6 +257,75 @@ function setHistoryCache(key: string, body: string, expiresAt: number) { historyCache.set(key, { body, expiresAt }); } +type ViewerVoteSide = "A" | "B"; + +function isValidFossabotValidateUrl(rawUrl: string): boolean { + try { + const url = new URL(rawUrl); + return ( + url.protocol === "https:" && + url.host === "api.fossabot.com" && + url.pathname.startsWith("/v2/customapi/validate/") + ); + } catch { + return false; + } +} + +async function validateFossabotRequest(validateUrl: string): Promise { + if (!isValidFossabotValidateUrl(validateUrl)) { + return false; + } + + const controller = new AbortController(); + const timeout = setTimeout( + () => controller.abort(), + FOSSABOT_VALIDATE_TIMEOUT_MS, + ); + try { + const res = await fetch(validateUrl, { + method: "GET", + signal: controller.signal, + }); + if (!res.ok) return false; + + const body = (await res.json().catch(() => null)) as + | { context_url?: unknown } + | null; + return Boolean(body && typeof body.context_url === "string"); + } catch { + return false; + } finally { + clearTimeout(timeout); + } +} + +function applyViewerVote(voterId: string, side: ViewerVoteSide): boolean { + const round = gameState.active; + if (!round || round.phase !== "voting") return false; + if (!round.viewerVotingEndsAt || Date.now() > round.viewerVotingEndsAt) { + return false; + } + + const previousVote = viewerVoters.get(voterId); + if (previousVote === side) return false; + + // Undo previous vote if this viewer switched sides. + if (previousVote === "A") { + round.viewerVotesA = Math.max(0, (round.viewerVotesA ?? 0) - 1); + } else if (previousVote === "B") { + round.viewerVotesB = Math.max(0, (round.viewerVotesB ?? 0) - 1); + } + + viewerVoters.set(voterId, side); + if (side === "A") { + round.viewerVotesA = (round.viewerVotesA ?? 0) + 1; + } else { + round.viewerVotesB = (round.viewerVotesB ?? 0) + 1; + } + return true; +} + // ── WebSocket clients ─────────────────────────────────────────────────────── const clients = new Set>(); @@ -344,6 +421,86 @@ const server = Bun.serve({ return new Response("ok", { status: 200 }); } + if ( + url.pathname === "/api/fossabot/vote/1" || + url.pathname === "/api/fossabot/vote/2" + ) { + if (req.method !== "GET") { + return new Response("", { + status: 405, + headers: { Allow: "GET" }, + }); + } + if (!FOSSABOT_VOTE_SECRET) { + log("ERROR", "vote:fossabot", "FOSSABOT_VOTE_SECRET is not configured"); + return new Response("", { status: 503 }); + } + + const providedSecret = url.searchParams.get("secret") ?? ""; + if (!providedSecret || !secureCompare(providedSecret, FOSSABOT_VOTE_SECRET)) { + log("WARN", "vote:fossabot", "Rejected due to missing/invalid secret", { + ip, + }); + return new Response("", { status: 401 }); + } + + const channelProvider = req.headers + .get("x-fossabot-channelprovider") + ?.trim() + .toLowerCase(); + const channelLogin = req.headers + .get("x-fossabot-channellogin") + ?.trim() + .toLowerCase(); + if (channelProvider !== "twitch" || channelLogin !== FOSSABOT_CHANNEL_LOGIN) { + log("WARN", "vote:fossabot", "Rejected due to channel/provider mismatch", { + ip, + channelProvider, + channelLogin, + }); + return new Response("", { status: 403 }); + } + + const validateUrl = req.headers.get("x-fossabot-validateurl") ?? ""; + const isValid = await validateFossabotRequest(validateUrl); + if (!isValid) { + log("WARN", "vote:fossabot", "Validation check failed", { ip }); + return new Response("", { status: 401 }); + } + + const userProvider = req.headers + .get("x-fossabot-message-userprovider") + ?.trim() + .toLowerCase(); + if (userProvider && userProvider !== "twitch") { + return new Response("", { status: 403 }); + } + + const userProviderId = req.headers + .get("x-fossabot-message-userproviderid") + ?.trim(); + const userLogin = req.headers + .get("x-fossabot-message-userlogin") + ?.trim() + .toLowerCase(); + const voterId = userProviderId || userLogin; + if (!voterId) { + return new Response("", { status: 400 }); + } + + const votedFor: ViewerVoteSide = url.pathname.endsWith("/1") ? "A" : "B"; + const applied = applyViewerVote(voterId, votedFor); + if (applied) { + scheduleViewerVoteBroadcast(); + } + return new Response("", { + status: 200, + headers: { + "Cache-Control": "no-store", + }, + }); + } + if (url.pathname === "/api/admin/login") { if (req.method !== "POST") { return new Response("Method Not Allowed", { @@ -646,31 +803,8 @@ const server = Bun.serve({ // Notify everyone else with just the viewer count broadcastViewerCount(); }, - message(ws, message) { - try { - const msg = JSON.parse(String(message)); - if (msg.type !== "vote") return; - - const round = gameState.active; - if (!round || round.phase !== "voting") return; - if (!round.viewerVotingEndsAt || Date.now() > round.viewerVotingEndsAt) return; - if (msg.votedFor !== "A" && msg.votedFor !== "B") return; - - const ip = ws.data.ip; - const previousVote = viewerVoters.get(ip); - if (previousVote === msg.votedFor) return; // same vote, ignore - - // Undo previous vote if changing - if (previousVote === "A") round.viewerVotesA = Math.max(0, (round.viewerVotesA ?? 0) - 1); - else if (previousVote === "B") round.viewerVotesB = Math.max(0, (round.viewerVotesB ?? 0) - 1); - - viewerVoters.set(ip, msg.votedFor); - if (msg.votedFor === "A") round.viewerVotesA = (round.viewerVotesA ?? 0) + 1; - else round.viewerVotesB = (round.viewerVotesB ?? 0) + 1; - - ws.send(JSON.stringify({ type: "votedAck", votedFor: msg.votedFor })); - scheduleViewerVoteBroadcast(); - } catch {} + message() { + // Viewer voting moved to Twitch chat via Fossabot. }, close(ws) { clients.delete(ws);