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);