fossabot setup
This commit is contained in:
13
frontend.css
13
frontend.css
@@ -138,6 +138,19 @@ body {
|
|||||||
color: var(--text-dim);
|
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 ───────────────────────────────────────────────────── */
|
||||||
|
|
||||||
.prompt {
|
.prompt {
|
||||||
|
|||||||
62
frontend.tsx
62
frontend.tsx
@@ -53,8 +53,7 @@ type ViewerCountMessage = {
|
|||||||
type: "viewerCount";
|
type: "viewerCount";
|
||||||
viewerCount: number;
|
viewerCount: number;
|
||||||
};
|
};
|
||||||
type VotedAckMessage = { type: "votedAck"; votedFor: "A" | "B" };
|
type ServerMessage = StateMessage | ViewerCountMessage;
|
||||||
type ServerMessage = StateMessage | ViewerCountMessage | VotedAckMessage;
|
|
||||||
|
|
||||||
// ── Model colors & logos ─────────────────────────────────────────────────────
|
// ── Model colors & logos ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -161,9 +160,6 @@ function ContestantCard({
|
|||||||
voters,
|
voters,
|
||||||
viewerVotes,
|
viewerVotes,
|
||||||
totalViewerVotes,
|
totalViewerVotes,
|
||||||
votable,
|
|
||||||
onVote,
|
|
||||||
isMyVote,
|
|
||||||
}: {
|
}: {
|
||||||
task: TaskInfo;
|
task: TaskInfo;
|
||||||
voteCount: number;
|
voteCount: number;
|
||||||
@@ -173,9 +169,6 @@ function ContestantCard({
|
|||||||
voters: VoteInfo[];
|
voters: VoteInfo[];
|
||||||
viewerVotes?: number;
|
viewerVotes?: number;
|
||||||
totalViewerVotes?: number;
|
totalViewerVotes?: number;
|
||||||
votable?: boolean;
|
|
||||||
onVote?: () => void;
|
|
||||||
isMyVote?: boolean;
|
|
||||||
}) {
|
}) {
|
||||||
const color = getColor(task.model.name);
|
const color = getColor(task.model.name);
|
||||||
const pct = totalVotes > 0 ? Math.round((voteCount / totalVotes) * 100) : 0;
|
const pct = totalVotes > 0 ? Math.round((voteCount / totalVotes) * 100) : 0;
|
||||||
@@ -186,16 +179,11 @@ function ContestantCard({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`contestant ${isWinner ? "contestant--winner" : ""} ${votable ? "contestant--votable" : ""} ${isMyVote ? "contestant--my-vote" : ""}`}
|
className={`contestant ${isWinner ? "contestant--winner" : ""}`}
|
||||||
style={{ "--accent": color } as React.CSSProperties}
|
style={{ "--accent": color } as React.CSSProperties}
|
||||||
onClick={votable ? onVote : undefined}
|
|
||||||
role={votable ? "button" : undefined}
|
|
||||||
tabIndex={votable ? 0 : undefined}
|
|
||||||
onKeyDown={votable ? (e) => { if (e.key === "Enter" || e.key === " ") onVote?.(); } : undefined}
|
|
||||||
>
|
>
|
||||||
<div className="contestant__head">
|
<div className="contestant__head">
|
||||||
<ModelTag model={task.model} />
|
<ModelTag model={task.model} />
|
||||||
{isMyVote && !isWinner && <span className="my-vote-tag">YOUR PICK</span>}
|
|
||||||
{isWinner && <span className="win-tag">WIN</span>}
|
{isWinner && <span className="win-tag">WIN</span>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -280,14 +268,10 @@ function ContestantCard({
|
|||||||
function Arena({
|
function Arena({
|
||||||
round,
|
round,
|
||||||
total,
|
total,
|
||||||
myVote,
|
|
||||||
onVote,
|
|
||||||
viewerVotingSecondsLeft,
|
viewerVotingSecondsLeft,
|
||||||
}: {
|
}: {
|
||||||
round: RoundState;
|
round: RoundState;
|
||||||
total: number | null;
|
total: number | null;
|
||||||
myVote: "A" | "B" | null;
|
|
||||||
onVote: (side: "A" | "B") => void;
|
|
||||||
viewerVotingSecondsLeft: number;
|
viewerVotingSecondsLeft: number;
|
||||||
}) {
|
}) {
|
||||||
const [contA, contB] = round.contestants;
|
const [contA, contB] = round.contestants;
|
||||||
@@ -305,12 +289,6 @@ function Arena({
|
|||||||
const votersB = round.votes.filter((v) => v.votedFor?.name === contB.name);
|
const votersB = round.votes.filter((v) => v.votedFor?.name === contB.name);
|
||||||
const totalViewerVotes = (round.viewerVotesA ?? 0) + (round.viewerVotesB ?? 0);
|
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 showCountdown = round.phase === "voting" && viewerVotingSecondsLeft > 0;
|
||||||
|
|
||||||
const phaseText =
|
const phaseText =
|
||||||
@@ -336,6 +314,11 @@ function Arena({
|
|||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
{showCountdown && (
|
||||||
|
<div className="vote-hint">
|
||||||
|
Vote in Twitch chat: <strong>1</strong> for left, <strong>2</strong> for right.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<PromptCard round={round} />
|
<PromptCard round={round} />
|
||||||
|
|
||||||
@@ -350,9 +333,6 @@ function Arena({
|
|||||||
voters={votersA}
|
voters={votersA}
|
||||||
viewerVotes={round.viewerVotesA}
|
viewerVotes={round.viewerVotesA}
|
||||||
totalViewerVotes={totalViewerVotes}
|
totalViewerVotes={totalViewerVotes}
|
||||||
votable={!!canVote}
|
|
||||||
onVote={() => onVote("A")}
|
|
||||||
isMyVote={myVote === "A"}
|
|
||||||
/>
|
/>
|
||||||
<ContestantCard
|
<ContestantCard
|
||||||
task={round.answerTasks[1]}
|
task={round.answerTasks[1]}
|
||||||
@@ -363,9 +343,6 @@ function Arena({
|
|||||||
voters={votersB}
|
voters={votersB}
|
||||||
viewerVotes={round.viewerVotesB}
|
viewerVotes={round.viewerVotesB}
|
||||||
totalViewerVotes={totalViewerVotes}
|
totalViewerVotes={totalViewerVotes}
|
||||||
votable={!!canVote}
|
|
||||||
onVote={() => onVote("B")}
|
|
||||||
isMyVote={myVote === "B"}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -490,19 +467,7 @@ function App() {
|
|||||||
const [totalRounds, setTotalRounds] = useState<number | null>(null);
|
const [totalRounds, setTotalRounds] = useState<number | null>(null);
|
||||||
const [viewerCount, setViewerCount] = useState(0);
|
const [viewerCount, setViewerCount] = useState(0);
|
||||||
const [connected, setConnected] = useState(false);
|
const [connected, setConnected] = useState(false);
|
||||||
const [myVote, setMyVote] = useState<"A" | "B" | null>(null);
|
|
||||||
const [votedRound, setVotedRound] = useState<number | null>(null);
|
|
||||||
const [viewerVotingSecondsLeft, setViewerVotingSecondsLeft] = useState(0);
|
const [viewerVotingSecondsLeft, setViewerVotingSecondsLeft] = useState(0);
|
||||||
const wsRef = React.useRef<WebSocket | null>(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
|
// Countdown timer for viewer voting
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -530,11 +495,9 @@ function App() {
|
|||||||
let knownVersion: string | null = null;
|
let knownVersion: string | null = null;
|
||||||
function connect() {
|
function connect() {
|
||||||
ws = new WebSocket(wsUrl);
|
ws = new WebSocket(wsUrl);
|
||||||
wsRef.current = ws;
|
|
||||||
ws.onopen = () => setConnected(true);
|
ws.onopen = () => setConnected(true);
|
||||||
ws.onclose = () => {
|
ws.onclose = () => {
|
||||||
setConnected(false);
|
setConnected(false);
|
||||||
wsRef.current = null;
|
|
||||||
reconnectTimer = setTimeout(connect, 2000);
|
reconnectTimer = setTimeout(connect, 2000);
|
||||||
};
|
};
|
||||||
ws.onmessage = (e) => {
|
ws.onmessage = (e) => {
|
||||||
@@ -549,8 +512,6 @@ function App() {
|
|||||||
setViewerCount(msg.viewerCount);
|
setViewerCount(msg.viewerCount);
|
||||||
} else if (msg.type === "viewerCount") {
|
} else if (msg.type === "viewerCount") {
|
||||||
setViewerCount(msg.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 <ConnectingScreen />;
|
if (!connected || !state) return <ConnectingScreen />;
|
||||||
|
|
||||||
const isNextPrompting =
|
const isNextPrompting =
|
||||||
@@ -606,8 +560,6 @@ function App() {
|
|||||||
<Arena
|
<Arena
|
||||||
round={displayRound}
|
round={displayRound}
|
||||||
total={totalRounds}
|
total={totalRounds}
|
||||||
myVote={myVote}
|
|
||||||
onVote={handleVote}
|
|
||||||
viewerVotingSecondsLeft={viewerVotingSecondsLeft}
|
viewerVotingSecondsLeft={viewerVotingSecondsLeft}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
184
server.ts
184
server.ts
@@ -90,6 +90,14 @@ const MAX_HISTORY_CACHE_KEYS = parsePositiveInt(
|
|||||||
process.env.MAX_HISTORY_CACHE_KEYS,
|
process.env.MAX_HISTORY_CACHE_KEYS,
|
||||||
500,
|
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 = "quipslop_admin";
|
||||||
const ADMIN_COOKIE_MAX_AGE_SECONDS = 60 * 60 * 24 * 30;
|
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 });
|
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<boolean> {
|
||||||
|
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 ───────────────────────────────────────────────────────
|
// ── WebSocket clients ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
const clients = new Set<ServerWebSocket<WsData>>();
|
const clients = new Set<ServerWebSocket<WsData>>();
|
||||||
@@ -344,6 +421,86 @@ const server = Bun.serve<WsData>({
|
|||||||
return new Response("ok", { status: 200 });
|
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 (url.pathname === "/api/admin/login") {
|
||||||
if (req.method !== "POST") {
|
if (req.method !== "POST") {
|
||||||
return new Response("Method Not Allowed", {
|
return new Response("Method Not Allowed", {
|
||||||
@@ -646,31 +803,8 @@ const server = Bun.serve<WsData>({
|
|||||||
// Notify everyone else with just the viewer count
|
// Notify everyone else with just the viewer count
|
||||||
broadcastViewerCount();
|
broadcastViewerCount();
|
||||||
},
|
},
|
||||||
message(ws, message) {
|
message() {
|
||||||
try {
|
// Viewer voting moved to Twitch chat via Fossabot.
|
||||||
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 {}
|
|
||||||
},
|
},
|
||||||
close(ws) {
|
close(ws) {
|
||||||
clients.delete(ws);
|
clients.delete(ws);
|
||||||
|
|||||||
Reference in New Issue
Block a user