diff --git a/.env b/.env index 2508c4e..5b1d91c 100644 --- a/.env +++ b/.env @@ -1,5 +1,5 @@ -CLIENT_ID=1d55t6jkkl2l0triq2irmog3lo3ver -CLIENT_SECRET=tmfe55kvjzcpv7coen38yxjzj7efhn +CLIENT_ID=uw6yemlkkf2efouaoln2dtgt33uobr +CLIENT_SECRET=o34pbqc9qk85a779zxo3vnwo3okmsq BROADCASTER_ID=86025329 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..872d5f6 --- /dev/null +++ b/.gitignore @@ -0,0 +1,143 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +lerna-debug.log* + +# Diagnostic reports (https://nodejs.org/api/report.html) +report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage +*.lcov + +# nyc test coverage +.nyc_output + +# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# Bower dependency directory (https://bower.io/) +bower_components + +# node-waf configuration +.lock-wscript + +# Compiled binary addons (https://nodejs.org/api/addons.html) +build/Release + +# Dependency directories +node_modules/ +jspm_packages/ + +# Snowpack dependency directory (https://snowpack.dev/) +web_modules/ + +# TypeScript cache +*.tsbuildinfo + +# Optional npm cache directory +.npm + +# Optional eslint cache +.eslintcache + +# Optional stylelint cache +.stylelintcache + +# Optional REPL history +.node_repl_history + +# Output of 'npm pack' +*.tgz + +# Yarn Integrity file +.yarn-integrity + +# dotenv environment variable files +.env +.env.* +!.env.example + +# parcel-bundler cache (https://parceljs.org/) +.cache +.parcel-cache + +# Next.js build output +.next +out + +# Nuxt.js build / generate output +.nuxt +dist +.output + +# Gatsby files +.cache/ +# Comment in the public line in if your project uses Gatsby and not Next.js +# https://nextjs.org/blog/next-9-1#public-directory-support +# public + +# vuepress build output +.vuepress/dist + +# vuepress v2.x temp directory +.temp + +# Sveltekit cache directory +.svelte-kit/ + +# vitepress build output +**/.vitepress/dist + +# vitepress cache directory +**/.vitepress/cache + +# Docusaurus cache and generated files +.docusaurus + +# Serverless directories +.serverless/ + +# FuseBox cache +.fusebox/ + +# DynamoDB Local files +.dynamodb/ + +# Firebase cache directory +.firebase/ + +# TernJS port file +.tern-port + +# Stores VSCode versions used for testing VSCode extensions +.vscode-test + +# pnpm +.pnpm-store + +# yarn v3 +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/sdks +!.yarn/versions + +# Vite files +vite.config.js.timestamp-* +vite.config.ts.timestamp-* +.vite/ diff --git a/src/config.js b/src/config.js index 6a3570d..37efb78 100644 --- a/src/config.js +++ b/src/config.js @@ -3,8 +3,12 @@ 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, diff --git a/src/index.js b/src/index.js index 1363864..79c2548 100644 --- a/src/index.js +++ b/src/index.js @@ -3,14 +3,16 @@ const { connect } = require("./twitch/websocket"); const auth = startAuthServer(); -// wait until token exists before starting twitch -const waitForToken = setInterval(() => { +// wait for login before starting Twitch connection +const wait = setInterval(() => { const token = auth.getToken(); if (token) { - clearInterval(waitForToken); + clearInterval(wait); - process.env.APP_ACCESS_TOKEN = token; + // inject token into runtime config + const config = require("./config"); + config.token = token; console.log("šŸš€ Starting Twitch connection..."); connect(); diff --git a/src/rewards/Hydrate.js b/src/rewards/Hydrate.js new file mode 100644 index 0000000..5dfedef --- /dev/null +++ b/src/rewards/Hydrate.js @@ -0,0 +1,6 @@ +module.exports = { + name: "Hydrate", + run: (event) => { + console.log("šŸ’§ Hydration triggered for", event.user_name); + } +}; \ No newline at end of file diff --git a/src/rewards/hydrate.js b/src/rewards/hydrate.js deleted file mode 100644 index 4541154..0000000 --- a/src/rewards/hydrate.js +++ /dev/null @@ -1,3 +0,0 @@ -module.exports = (event) => { - console.log("šŸ’§ Hydrate triggered by", event.user_name); -}; \ No newline at end of file diff --git a/src/rewards/index.js b/src/rewards/index.js new file mode 100644 index 0000000..2897715 --- /dev/null +++ b/src/rewards/index.js @@ -0,0 +1,29 @@ +const fs = require("fs"); +const path = require("path"); + +const rewards = 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; + } + + rewards.set(reward.name.toLowerCase(), reward.run); + console.log(`šŸ“¦ Loaded reward: ${reward.name}`); + } +} + +function getReward(name) { + return rewards.get(name.toLowerCase()); +} + +module.exports = { loadRewards, getReward }; \ No newline at end of file diff --git a/src/rewards/sound.js b/src/rewards/sound.js index 324da4b..2e776cb 100644 --- a/src/rewards/sound.js +++ b/src/rewards/sound.js @@ -1,5 +1,6 @@ -module.exports = (event) => { - console.log("šŸ”Š Sound triggered by", event.user_name); - - // later: play sound, trigger OBS, etc. +module.exports = { + name: "Sound", + run: (event) => { + console.log("šŸ”Š Sound triggered for", event.user_name); + } }; \ No newline at end of file diff --git a/src/twitch/auth.js b/src/twitch/auth.js deleted file mode 100644 index 02e5cb1..0000000 --- a/src/twitch/auth.js +++ /dev/null @@ -1,35 +0,0 @@ -const axios = require("axios"); -const config = require("../config"); - -let tokenCache = null; -let expiresAt = 0; - -async function getAppToken() { - const now = Date.now(); - - // reuse token if still valid - if (tokenCache && now < expiresAt) { - return tokenCache; - } - - const res = await axios.post( - "https://id.twitch.tv/oauth2/token", - null, - { - params: { - client_id: config.clientId, - client_secret: config.clientSecret, - grant_type: "client_credentials", - }, - } - ); - - tokenCache = res.data.access_token; - expiresAt = now + res.data.expires_in * 1000 - 60000; // refresh 1 min early - - console.log("šŸ” New Twitch App Token generated"); - - return tokenCache; -} - -module.exports = { getAppToken }; \ No newline at end of file diff --git a/src/twitch/authServer.js b/src/twitch/authServer.js index 1ada24b..62ac4e2 100644 --- a/src/twitch/authServer.js +++ b/src/twitch/authServer.js @@ -1,22 +1,29 @@ const http = require("http"); const axios = require("axios"); const config = require("../config"); +const { setTokens, startAutoRefresh } = require("./token"); -let accessToken = null; +let authServerStarted = false; function startAuthServer() { + if (authServerStarted) return; + authServerStarted = true; + 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 (!code) { - res.end("Missing code"); + res.writeHead(400); + res.end("Missing OAuth code"); return; } try { + // Exchange code for tokens const tokenRes = await axios.post( "https://id.twitch.tv/oauth2/token", null, @@ -31,35 +38,42 @@ function startAuthServer() { } ); - accessToken = tokenRes.data.access_token; + const accessToken = tokenRes.data.access_token; + const refreshToken = tokenRes.data.refresh_token; + const expiresIn = tokenRes.data.expires_in; - console.log("šŸ” Twitch user token acquired!"); + // Store tokens globally in auth system + setTokens(accessToken, refreshToken); - res.end("Auth successful! You can close this tab."); + // Start auto-refresh loop + startAutoRefresh(); + + console.log("šŸ” Twitch authentication successful"); + console.log(`ā± Token expires in: ${expiresIn}s`); + + res.writeHead(200, { "Content-Type": "text/plain" }); + res.end("Authentication successful. You can close this window."); } catch (err) { - console.error(err.response?.data || err.message); - res.end("Auth failed"); + console.error("OAuth token exchange failed:", err.response?.data || err.message); + + res.writeHead(500); + res.end("Authentication failed. Check terminal logs."); } } }); server.listen(3000, () => { - console.log("\nšŸ‘‰ Open this URL to login:\n"); - - const url = - `https://id.twitch.tv/oauth2/authorize` + + const authUrl = + "https://id.twitch.tv/oauth2/authorize" + `?client_id=${config.clientId}` + `&redirect_uri=http://localhost:3000/callback` + `&response_type=code` + `&scope=channel:read:redemptions`; - console.log(url + "\n"); + console.log("\nšŸ‘‰ Twitch Login URL:"); + 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 05d6950..29f313c 100644 --- a/src/twitch/handler.js +++ b/src/twitch/handler.js @@ -1,9 +1,6 @@ -const config = require("../config"); +const { loadRewards, getReward } = require("../rewards"); -const hydrate = require("../rewards/hydrate"); -const sound = require("../rewards/sound"); - -const seen = new Set(); +loadRewards(); function handleEvent(payload) { const { subscription, event } = payload; @@ -15,24 +12,18 @@ function handleEvent(payload) { return; } - if (seen.has(event.id)) return; - seen.add(event.id); - setTimeout(() => seen.delete(event.id), 60000); + const rewardName = event.reward.title; - console.log(`šŸŽ ${event.user_name} → ${event.reward.title}`); + console.log(`šŸŽ ${event.user_name} → ${rewardName}`); - switch (event.reward.id) { - case config.rewards.HYDRATE: - hydrate(event); - break; + const reward = getReward(rewardName); - case config.rewards.SOUND: - sound(event); - break; - - default: - console.log("Unknown reward:", event.reward.id); + if (!reward) { + console.log("Unknown reward:", rewardName); + return; } + + reward(event); } module.exports = { handleEvent }; \ No newline at end of file diff --git a/src/twitch/subscriptions.js b/src/twitch/subscriptions.js index c025489..f3e9df0 100644 --- a/src/twitch/subscriptions.js +++ b/src/twitch/subscriptions.js @@ -1,12 +1,12 @@ const axios = require("axios"); const config = require("../config"); -const { getAppToken } = require("./auth"); +const { getAccessToken } = require("./token"); async function createSubscription(sessionId) { try { - const token = await getAppToken(); + const token = getAccessToken(); - await axios.post( + const res = await axios.post( "https://api.twitch.tv/helix/eventsub/subscriptions", { type: "channel.channel_points_custom_reward_redemption.add", @@ -30,10 +30,7 @@ async function createSubscription(sessionId) { console.log("šŸ“” Subscription created"); } catch (err) { - console.error( - "āŒ Subscription error:", - err.response?.data || err.message - ); + console.error("āŒ Subscription error:", err.response?.data || err.message); } } diff --git a/src/twitch/token.js b/src/twitch/token.js new file mode 100644 index 0000000..d7828a0 --- /dev/null +++ b/src/twitch/token.js @@ -0,0 +1,52 @@ +const axios = require("axios"); +const config = require("../config"); + +let accessToken = null; +let refreshToken = null; + +function setTokens(initialAccess, initialRefresh) { + accessToken = initialAccess; + refreshToken = initialRefresh; +} + +function getAccessToken() { + return accessToken; +} + +async function refreshAccessToken() { + if (!refreshToken) return; + + const res = await axios.post( + "https://id.twitch.tv/oauth2/token", + null, + { + params: { + grant_type: "refresh_token", + refresh_token: refreshToken, + client_id: config.clientId, + client_secret: config.clientSecret, + }, + } + ); + + accessToken = res.data.access_token; + refreshToken = res.data.refresh_token; + + console.log("šŸ”„ Twitch token refreshed"); + + return accessToken; +} + +function startAutoRefresh() { + // refresh every 3 hours (safe before expiry) + setInterval(() => { + refreshAccessToken().catch(console.error); + }, 3 * 60 * 60 * 1000); +} + +module.exports = { + setTokens, + getAccessToken, + refreshAccessToken, + startAutoRefresh, +}; \ No newline at end of file diff --git a/src/twitch/websocket.js b/src/twitch/websocket.js index c943554..2a244bc 100644 --- a/src/twitch/websocket.js +++ b/src/twitch/websocket.js @@ -25,6 +25,7 @@ function connect(url = reconnectUrl) { payload.session.reconnect_url || "wss://eventsub.wss.twitch.tv/ws"; + // IMPORTANT: must be immediate await createSubscription(sessionId); break; }