diff --git a/game.ts b/game.ts index 044e3f9..44bdc82 100644 --- a/game.ts +++ b/game.ts @@ -272,7 +272,7 @@ export async function runGame( runs: number, state: GameState, rerender: () => void, - onViewerVotingStart?: () => void, + onViewerVotingStart?: (round: RoundState) => void, ) { let startRound = 1; const lastCompletedRound = state.completed.at(-1); @@ -403,7 +403,7 @@ export async function runGame( round.viewerVotesA = 0; round.viewerVotesB = 0; round.viewerVotingEndsAt = Date.now() + 30_000; - onViewerVotingStart?.(); + onViewerVotingStart?.(round); rerender(); await Promise.all([ diff --git a/server.ts b/server.ts index 7f26f0f..b3c2709 100644 --- a/server.ts +++ b/server.ts @@ -105,10 +105,18 @@ const FOSSABOT_CHANNEL_LOGIN = ( process.env.FOSSABOT_CHANNEL_LOGIN ?? "quipslop" ).trim().toLowerCase(); const FOSSABOT_VOTE_SECRET = process.env.FOSSABOT_VOTE_SECRET ?? ""; +const FOSSABOT_CHAT_CHANNEL_ID = ( + process.env.FOSSABOT_CHAT_CHANNEL_ID ?? "813591620327550976" +).trim(); +const FOSSABOT_SESSION_TOKEN = (process.env.FOSSABOT_SESSION_TOKEN ?? "").trim(); const FOSSABOT_VALIDATE_TIMEOUT_MS = parsePositiveInt( process.env.FOSSABOT_VALIDATE_TIMEOUT_MS, 1_500, ); +const FOSSABOT_SEND_CHAT_TIMEOUT_MS = parsePositiveInt( + process.env.FOSSABOT_SEND_CHAT_TIMEOUT_MS, + 3_000, +); const VIEWER_VOTE_BROADCAST_DEBOUNCE_MS = parsePositiveInt( process.env.VIEWER_VOTE_BROADCAST_DEBOUNCE_MS, 250, @@ -315,6 +323,69 @@ async function validateFossabotRequest(validateUrl: string): Promise { } } +async function sendFossabotChatMessage(messageText: string): Promise { + if (!FOSSABOT_SESSION_TOKEN) { + log( + "WARN", + "fossabot:chat", + "Skipped chat message because FOSSABOT_SESSION_TOKEN is not configured", + ); + return; + } + if (!FOSSABOT_CHAT_CHANNEL_ID) { + log( + "WARN", + "fossabot:chat", + "Skipped chat message because FOSSABOT_CHAT_CHANNEL_ID is not configured", + ); + return; + } + + const controller = new AbortController(); + const timeout = setTimeout( + () => controller.abort(), + FOSSABOT_SEND_CHAT_TIMEOUT_MS, + ); + + try { + const url = `https://api.fossabot.com/v2/channels/${FOSSABOT_CHAT_CHANNEL_ID}/bot/send_chat_message`; + const res = await fetch(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${FOSSABOT_SESSION_TOKEN}`, + }, + body: JSON.stringify({ messageText }), + signal: controller.signal, + }); + + if (!res.ok) { + const body = await res.text().catch(() => ""); + log("WARN", "fossabot:chat", "Fossabot send_chat_message failed", { + status: res.status, + body: body.slice(0, 250), + }); + return; + } + + const response = (await res.json().catch(() => null)) as + | { transactionId?: unknown } + | null; + log("INFO", "fossabot:chat", "Sent voting prompt to Twitch chat", { + transactionId: + response && typeof response.transactionId === "string" + ? response.transactionId + : undefined, + }); + } catch (error) { + log("WARN", "fossabot:chat", "Failed to send chat message", { + error: error instanceof Error ? error.message : String(error), + }); + } finally { + clearTimeout(timeout); + } +} + function applyViewerVote(voterId: string, side: ViewerVoteSide): boolean { const round = gameState.active; if (!round || round.phase !== "voting") return false; @@ -857,8 +928,12 @@ log("INFO", "server", `Web server started on port ${server.port}`, { // ── Start game ────────────────────────────────────────────────────────────── -runGame(runs, gameState, broadcast, () => { +runGame(runs, gameState, broadcast, (round) => { viewerVoters.clear(); + + const [modelA, modelB] = round.contestants; + const messageText = `1 in chat for ${modelA.name}, 2 in chat for ${modelB.name}`; + void sendFossabotChatMessage(messageText); }).then(() => { console.log(`\n✅ Game complete! Log: ${LOG_FILE}`); });