From 12baf6a12bdbbeeb5e39fa9111fc52b5054baf91 Mon Sep 17 00:00:00 2001 From: Reaper Date: Thu, 23 Apr 2026 04:49:00 +0200 Subject: [PATCH] edit --- .gitignore | 3 + src/config.js | 9 --- src/index.js | 27 ++++----- src/rewards/Hydrate.js | 4 +- src/rewards/index.js | 15 ++--- src/rewards/sound.js | 4 +- src/twitch/authServer.js | 97 +++++++++++++++++++-------------- src/twitch/handler.js | 19 ++----- src/twitch/subscriptionState.js | 11 ++++ src/twitch/subscriptions.js | 7 +-- src/twitch/token.js | 36 ++++++++---- src/twitch/tokenStore.js | 23 ++++++++ src/twitch/websocket.js | 22 +++----- 13 files changed, 159 insertions(+), 118 deletions(-) create mode 100644 src/twitch/subscriptionState.js create mode 100644 src/twitch/tokenStore.js diff --git a/.gitignore b/.gitignore index 872d5f6..89d25ae 100644 --- a/.gitignore +++ b/.gitignore @@ -141,3 +141,6 @@ dist vite.config.js.timestamp-* vite.config.ts.timestamp-* .vite/ + + +token.json \ No newline at end of file diff --git a/src/config.js b/src/config.js index 37efb78..34ffdc6 100644 --- a/src/config.js +++ b/src/config.js @@ -3,14 +3,5 @@ require("dotenv").config(); module.exports = { clientId: process.env.CLIENT_ID, clientSecret: process.env.CLIENT_SECRET, - broadcasterId: process.env.BROADCASTER_ID, - - // THIS becomes your USER token after login - token: null, - - rewards: { - HYDRATE: process.env.REWARD_HYDRATE_ID, - SOUND: process.env.REWARD_SOUND_ID, - }, }; \ No newline at end of file diff --git a/src/index.js b/src/index.js index 79c2548..e3340de 100644 --- a/src/index.js +++ b/src/index.js @@ -1,20 +1,21 @@ const { startAuthServer } = require("./twitch/authServer"); const { connect } = require("./twitch/websocket"); +const { initFromDisk } = require("./twitch/token"); -const auth = startAuthServer(); +const restored = initFromDisk(); -// wait for login before starting Twitch connection -const wait = setInterval(() => { - const token = auth.getToken(); +if (restored) { + console.log("šŸš€ Using saved token, skipping login..."); + connect(); +} else { + const auth = startAuthServer(); + + const wait = setInterval(() => { + const token = auth.getToken(); + + if (!token) return; - if (token) { clearInterval(wait); - - // inject token into runtime config - const config = require("./config"); - config.token = token; - - console.log("šŸš€ Starting Twitch connection..."); connect(); - } -}, 1000); \ No newline at end of file + }, 1000); +} \ No newline at end of file diff --git a/src/rewards/Hydrate.js b/src/rewards/Hydrate.js index 5dfedef..22b87cd 100644 --- a/src/rewards/Hydrate.js +++ b/src/rewards/Hydrate.js @@ -1,6 +1,6 @@ module.exports = { name: "Hydrate", run: (event) => { - console.log("šŸ’§ Hydration triggered for", event.user_name); - } + console.log("šŸ’§ Hydrate triggered:", event.user_name); + }, }; \ No newline at end of file diff --git a/src/rewards/index.js b/src/rewards/index.js index 2897715..1fd645b 100644 --- a/src/rewards/index.js +++ b/src/rewards/index.js @@ -1,29 +1,26 @@ const fs = require("fs"); const path = require("path"); -const rewards = new Map(); +const rewardMap = new Map(); function loadRewards() { const files = fs.readdirSync(__dirname); for (const file of files) { if (file === "index.js") continue; - if (!file.endsWith(".js")) continue; const reward = require(path.join(__dirname, file)); - if (!reward.name || !reward.run) { - console.warn(`āš ļø Invalid reward file: ${file}`); - continue; - } + rewardMap.set(reward.name.toLowerCase(), reward.run); - rewards.set(reward.name.toLowerCase(), reward.run); console.log(`šŸ“¦ Loaded reward: ${reward.name}`); } } function getReward(name) { - return rewards.get(name.toLowerCase()); + return rewardMap.get(name.toLowerCase()); } -module.exports = { loadRewards, getReward }; \ No newline at end of file +loadRewards(); + +module.exports = { getReward }; \ No newline at end of file diff --git a/src/rewards/sound.js b/src/rewards/sound.js index 2e776cb..38a3214 100644 --- a/src/rewards/sound.js +++ b/src/rewards/sound.js @@ -1,6 +1,6 @@ module.exports = { name: "Sound", run: (event) => { - console.log("šŸ”Š Sound triggered for", event.user_name); - } + console.log("šŸ”Š Sound triggered:", event.user_name); + }, }; \ No newline at end of file diff --git a/src/twitch/authServer.js b/src/twitch/authServer.js index 62ac4e2..c977770 100644 --- a/src/twitch/authServer.js +++ b/src/twitch/authServer.js @@ -1,8 +1,11 @@ const http = require("http"); const axios = require("axios"); const config = require("../config"); -const { setTokens, startAutoRefresh } = require("./token"); +const { setTokens, startAutoRefresh } = require("./token"); +const { saveTokenData } = require("./tokenStore"); + +let accessToken = null; let authServerStarted = false; function startAuthServer() { @@ -12,54 +15,62 @@ function startAuthServer() { const server = http.createServer(async (req, res) => { const url = new URL(req.url, "http://localhost:3000"); - // OAuth callback endpoint - if (url.pathname === "/callback") { - const code = url.searchParams.get("code"); + if (url.pathname !== "/callback") return; - if (!code) { - res.writeHead(400); - res.end("Missing OAuth code"); - return; - } + const code = url.searchParams.get("code"); - try { - // Exchange code for tokens - const tokenRes = await axios.post( - "https://id.twitch.tv/oauth2/token", - null, - { - params: { - client_id: config.clientId, - client_secret: config.clientSecret, - code, - grant_type: "authorization_code", - redirect_uri: "http://localhost:3000/callback", - }, - } - ); + if (!code) { + res.writeHead(400); + res.end("Missing OAuth code"); + return; + } - const accessToken = tokenRes.data.access_token; - const refreshToken = tokenRes.data.refresh_token; - const expiresIn = tokenRes.data.expires_in; + try { + // Exchange code for tokens + const tokenRes = await axios.post( + "https://id.twitch.tv/oauth2/token", + null, + { + params: { + client_id: config.clientId, + client_secret: config.clientSecret, + code, + grant_type: "authorization_code", + redirect_uri: "http://localhost:3000/callback", + }, + } + ); - // Store tokens globally in auth system - setTokens(accessToken, refreshToken); + const access = tokenRes.data.access_token; + const refresh = tokenRes.data.refresh_token; - // Start auto-refresh loop - startAutoRefresh(); + // Update runtime token system + setTokens(access, refresh); + startAutoRefresh(); - console.log("šŸ” Twitch authentication successful"); - console.log(`ā± Token expires in: ${expiresIn}s`); + // Persist to disk + saveTokenData({ + accessToken: access, + refreshToken: refresh, + savedAt: Date.now(), + }); - res.writeHead(200, { "Content-Type": "text/plain" }); - res.end("Authentication successful. You can close this window."); + accessToken = access; - } catch (err) { - console.error("OAuth token exchange failed:", err.response?.data || err.message); + console.log("šŸ” Twitch authentication successful"); + console.log("šŸ’¾ Tokens saved to disk"); - res.writeHead(500); - res.end("Authentication failed. Check terminal logs."); - } + res.writeHead(200, { "Content-Type": "text/plain" }); + res.end("Authentication successful. You can close this tab."); + + } catch (err) { + console.error( + "OAuth token exchange failed:", + err.response?.data || err.message + ); + + res.writeHead(500); + res.end("Authentication failed. Check terminal logs."); } }); @@ -71,9 +82,13 @@ function startAuthServer() { `&response_type=code` + `&scope=channel:read:redemptions`; - console.log("\nšŸ‘‰ Twitch Login URL:"); + console.log("\nšŸ‘‰ Twitch Login URL:\n"); console.log(authUrl + "\n"); }); + + return { + getToken: () => accessToken, + }; } module.exports = { startAuthServer }; \ No newline at end of file diff --git a/src/twitch/handler.js b/src/twitch/handler.js index 29f313c..c8d6b33 100644 --- a/src/twitch/handler.js +++ b/src/twitch/handler.js @@ -1,29 +1,20 @@ -const { loadRewards, getReward } = require("../rewards"); - -loadRewards(); +const rewards = require("../rewards"); function handleEvent(payload) { - const { subscription, event } = payload; - - if ( - subscription.type !== - "channel.channel_points_custom_reward_redemption.add" - ) { - return; - } + const event = payload.event; const rewardName = event.reward.title; console.log(`šŸŽ ${event.user_name} → ${rewardName}`); - const reward = getReward(rewardName); + const handler = rewards.getReward(rewardName); - if (!reward) { + if (!handler) { console.log("Unknown reward:", rewardName); return; } - reward(event); + handler(event); } module.exports = { handleEvent }; \ No newline at end of file diff --git a/src/twitch/subscriptionState.js b/src/twitch/subscriptionState.js new file mode 100644 index 0000000..91ac27e --- /dev/null +++ b/src/twitch/subscriptionState.js @@ -0,0 +1,11 @@ +const sessions = new Set(); + +function hasSession(id) { + return sessions.has(id); +} + +function markSession(id) { + sessions.add(id); +} + +module.exports = { hasSession, markSession }; \ No newline at end of file diff --git a/src/twitch/subscriptions.js b/src/twitch/subscriptions.js index f3e9df0..41b6b00 100644 --- a/src/twitch/subscriptions.js +++ b/src/twitch/subscriptions.js @@ -6,7 +6,7 @@ async function createSubscription(sessionId) { try { const token = getAccessToken(); - const res = await axios.post( + await axios.post( "https://api.twitch.tv/helix/eventsub/subscriptions", { type: "channel.channel_points_custom_reward_redemption.add", @@ -23,14 +23,13 @@ async function createSubscription(sessionId) { headers: { "Client-ID": config.clientId, Authorization: `Bearer ${token}`, - "Content-Type": "application/json", }, } ); console.log("šŸ“” Subscription created"); - } catch (err) { - console.error("āŒ Subscription error:", err.response?.data || err.message); + } catch (e) { + console.error("āŒ Subscription error:", e.response?.data || e.message); } } diff --git a/src/twitch/token.js b/src/twitch/token.js index d7828a0..c477be8 100644 --- a/src/twitch/token.js +++ b/src/twitch/token.js @@ -1,15 +1,35 @@ const axios = require("axios"); const config = require("../config"); +const { saveTokenData, loadTokenData } = require("./tokenStore"); let accessToken = null; let refreshToken = null; -function setTokens(initialAccess, initialRefresh) { - accessToken = initialAccess; - refreshToken = initialRefresh; +function initFromDisk() { + const data = loadTokenData(); + + if (!data) return false; + + accessToken = data.accessToken; + refreshToken = data.refreshToken; + + console.log("šŸ’¾ Loaded token from disk"); + return true; +} + +function setTokens(at, rt) { + accessToken = at; + refreshToken = rt; + + saveTokenData({ + accessToken, + refreshToken, + savedAt: Date.now(), + }); } function getAccessToken() { + if (!accessToken) throw new Error("Token not ready"); return accessToken; } @@ -29,16 +49,12 @@ async function refreshAccessToken() { } ); - accessToken = res.data.access_token; - refreshToken = res.data.refresh_token; + setTokens(res.data.access_token, res.data.refresh_token); - console.log("šŸ”„ Twitch token refreshed"); - - return accessToken; + console.log("šŸ”„ Token refreshed + saved"); } function startAutoRefresh() { - // refresh every 3 hours (safe before expiry) setInterval(() => { refreshAccessToken().catch(console.error); }, 3 * 60 * 60 * 1000); @@ -47,6 +63,6 @@ function startAutoRefresh() { module.exports = { setTokens, getAccessToken, - refreshAccessToken, startAutoRefresh, + initFromDisk, }; \ No newline at end of file diff --git a/src/twitch/tokenStore.js b/src/twitch/tokenStore.js new file mode 100644 index 0000000..3fcbd52 --- /dev/null +++ b/src/twitch/tokenStore.js @@ -0,0 +1,23 @@ +const fs = require("fs"); +const path = require("path"); + +const FILE = path.join(__dirname, "../../token.json"); + +function saveTokenData(data) { + fs.writeFileSync(FILE, JSON.stringify(data, null, 2)); +} + +function loadTokenData() { + if (!fs.existsSync(FILE)) return null; + + try { + return JSON.parse(fs.readFileSync(FILE, "utf-8")); + } catch (e) { + return null; + } +} + +module.exports = { + saveTokenData, + loadTokenData, +}; \ No newline at end of file diff --git a/src/twitch/websocket.js b/src/twitch/websocket.js index 2a244bc..5c68caa 100644 --- a/src/twitch/websocket.js +++ b/src/twitch/websocket.js @@ -1,6 +1,7 @@ const WebSocket = require("ws"); -const { createSubscription } = require("./subscriptions"); const { handleEvent } = require("./handler"); +const { createSubscription } = require("./subscriptions"); +const { markSession, hasSession } = require("./subscriptionState"); let reconnectUrl = "wss://eventsub.wss.twitch.tv/ws"; @@ -19,13 +20,14 @@ function connect(url = reconnectUrl) { case "session_welcome": { const sessionId = payload.session.id; - console.log("🟢 Session:", sessionId); + if (hasSession(sessionId)) return; + markSession(sessionId); reconnectUrl = - payload.session.reconnect_url || - "wss://eventsub.wss.twitch.tv/ws"; + payload.session.reconnect_url || reconnectUrl; + + console.log("🟢 Session:", sessionId); - // IMPORTANT: must be immediate await createSubscription(sessionId); break; } @@ -35,12 +37,8 @@ function connect(url = reconnectUrl) { break; case "session_reconnect": - console.log("ā™»ļø Reconnecting..."); ws.close(); - connect(payload?.session?.reconnect_url || reconnectUrl); - break; - - case "session_keepalive": + connect(payload.session.reconnect_url); break; } }); @@ -49,10 +47,6 @@ function connect(url = reconnectUrl) { console.log("āŒ Disconnected. Reconnecting..."); setTimeout(() => connect(), 3000); }); - - ws.on("error", (err) => { - console.error("WebSocket error:", err.message); - }); } module.exports = { connect }; \ No newline at end of file