From 40410b1412d969d49a3a2aa6308520ce1a5caa36 Mon Sep 17 00:00:00 2001 From: John Costa Date: Mon, 30 Jun 2025 18:09:40 +0100 Subject: [PATCH] feat: user following --- biome.json | 34 ++++++++++++ bun.lock | 29 ++++++++++ package.json | 5 ++ src/lib/index.ts | 38 ++++++++----- src/lib/twitch.ts | 90 +++++++++++++++++++++++++++++++ src/routes/+page.server.ts | 6 +-- src/routes/api/webhook/+server.ts | 61 +++++++++++++++++++++ 7 files changed, 246 insertions(+), 17 deletions(-) create mode 100644 biome.json create mode 100644 src/lib/twitch.ts create mode 100644 src/routes/api/webhook/+server.ts diff --git a/biome.json b/biome.json new file mode 100644 index 0000000..9dc36a5 --- /dev/null +++ b/biome.json @@ -0,0 +1,34 @@ +{ + "$schema": "https://biomejs.dev/schemas/2.0.6/schema.json", + "vcs": { + "enabled": false, + "clientKind": "git", + "useIgnoreFile": false + }, + "files": { + "ignoreUnknown": false + }, + "formatter": { + "enabled": true, + "indentStyle": "tab" + }, + "linter": { + "enabled": false, + "rules": { + "recommended": true + } + }, + "javascript": { + "formatter": { + "quoteStyle": "double" + } + }, + "assist": { + "enabled": true, + "actions": { + "source": { + "organizeImports": "on" + } + } + } +} diff --git a/bun.lock b/bun.lock index 49ee080..22e67e3 100644 --- a/bun.lock +++ b/bun.lock @@ -3,11 +3,16 @@ "workspaces": { "": { "name": "twitchlight", + "dependencies": { + "zod": "^3.25.67", + }, "devDependencies": { + "@biomejs/biome": "2.0.6", "@sveltejs/adapter-auto": "^6.0.0", "@sveltejs/kit": "^2.16.0", "@sveltejs/vite-plugin-svelte": "^5.0.0", "@tailwindcss/vite": "^4.0.0", + "@types/node": "^24.0.7", "svelte": "^5.0.0", "svelte-check": "^4.0.0", "tailwindcss": "^4.0.0", @@ -19,6 +24,24 @@ "packages": { "@ampproject/remapping": ["@ampproject/remapping@2.3.0", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw=="], + "@biomejs/biome": ["@biomejs/biome@2.0.6", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.0.6", "@biomejs/cli-darwin-x64": "2.0.6", "@biomejs/cli-linux-arm64": "2.0.6", "@biomejs/cli-linux-arm64-musl": "2.0.6", "@biomejs/cli-linux-x64": "2.0.6", "@biomejs/cli-linux-x64-musl": "2.0.6", "@biomejs/cli-win32-arm64": "2.0.6", "@biomejs/cli-win32-x64": "2.0.6" }, "bin": { "biome": "bin/biome" } }, "sha512-RRP+9cdh5qwe2t0gORwXaa27oTOiQRQvrFf49x2PA1tnpsyU7FIHX4ZOFMtBC4QNtyWsN7Dqkf5EDbg4X+9iqA=="], + + "@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.0.6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-AzdiNNjNzsE6LfqWyBvcL29uWoIuZUkndu+wwlXW13EKcBHbbKjNQEZIJKYDc6IL+p7bmWGx3v9ZtcRyIoIz5A=="], + + "@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.0.6", "", { "os": "darwin", "cpu": "x64" }, "sha512-wJjjP4E7bO4WJmiQaLnsdXMa516dbtC6542qeRkyJg0MqMXP0fvs4gdsHhZ7p9XWTAmGIjZHFKXdsjBvKGIJJQ=="], + + "@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.0.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-ZSVf6TYo5rNMUHIW1tww+rs/krol7U5A1Is/yzWyHVZguuB0lBnIodqyFuwCNqG9aJGyk7xIMS8HG0qGUPz0SA=="], + + "@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.0.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-CVPEMlin3bW49sBqLBg2x016Pws7eUXA27XYDFlEtponD0luYjg2zQaMJ2nOqlkKG9fqzzkamdYxHdMDc2gZFw=="], + + "@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.0.6", "", { "os": "linux", "cpu": "x64" }, "sha512-geM1MkHTV1Kh2Cs/Xzot9BOF3WBacihw6bkEmxkz4nSga8B9/hWy5BDiOG3gHDGIBa8WxT0nzsJs2f/hPqQIQw=="], + + "@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.0.6", "", { "os": "linux", "cpu": "x64" }, "sha512-mKHE/e954hR/hSnAcJSjkf4xGqZc/53Kh39HVW1EgO5iFi0JutTN07TSjEMg616julRtfSNJi0KNyxvc30Y4rQ=="], + + "@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.0.6", "", { "os": "win32", "cpu": "arm64" }, "sha512-290V4oSFoKaprKE1zkYVsDfAdn0An5DowZ+GIABgjoq1ndhvNxkJcpxPsiYtT7slbVe3xmlT0ncdfOsN7KruzA=="], + + "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.0.6", "", { "os": "win32", "cpu": "x64" }, "sha512-bfM1Bce0d69Ao7pjTjUS+AWSZ02+5UHdiAP85Th8e9yV5xzw6JrHXbL5YWlcEKQ84FIZMdDc7ncuti1wd2sdbw=="], + "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.5", "", { "os": "aix", "cpu": "ppc64" }, "sha512-9o3TMmpmftaCMepOdA5k/yDw8SfInyzWWTjYTFCX3kPSDJMROQTb8jg+h9Cnwnmm1vOzvxN7gIfB5V2ewpjtGA=="], "@esbuild/android-arm": ["@esbuild/android-arm@0.25.5", "", { "os": "android", "cpu": "arm" }, "sha512-AdJKSPeEHgi7/ZhuIPtcQKr5RQdo6OO2IL87JkianiMYMPbCtot9fxPbrMiBADOWWm3T2si9stAiVsGbTQFkbA=="], @@ -165,6 +188,8 @@ "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], + "@types/node": ["@types/node@24.0.7", "", { "dependencies": { "undici-types": "~7.8.0" } }, "sha512-YIEUUr4yf8q8oQoXPpSlnvKNVKDQlPMWrmOcgzoduo7kvA2UF0/BwJ/eMKFTiTtkNL17I0M6Xe2tvwFU7be6iw=="], + "acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="], "aria-query": ["aria-query@5.3.2", "", {}, "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw=="], @@ -281,6 +306,8 @@ "typescript": ["typescript@5.8.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ=="], + "undici-types": ["undici-types@7.8.0", "", {}, "sha512-9UJ2xGDvQ43tYyVMpuHlsgApydB8ZKfVYTsLDhXkFL/6gfkp+U8xTGdh8pMJv1SpZna0zxG1DwsKZsreLbXBxw=="], + "vite": ["vite@6.3.5", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-cZn6NDFE7wdTpINgs++ZJ4N49W2vRp8LCKrn3Ob1kYNtOo21vfDoaV5GzBfLU4MovSAB8uNRm4jgzVQZ+mBzPQ=="], "vitefu": ["vitefu@1.0.7", "", { "peerDependencies": { "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0-beta.0" }, "optionalPeers": ["vite"] }, "sha512-eRWXLBbJjW3X5z5P5IHcSm2yYbYRPb2kQuc+oqsbAl99WB5kVsPbiiox+cymo8twTzifA6itvhr2CmjnaZZp0Q=="], @@ -289,6 +316,8 @@ "zimmerframe": ["zimmerframe@1.1.2", "", {}, "sha512-rAbqEGa8ovJy4pyBxZM70hg4pE6gDgaQ0Sl9M3enG3I0d6H4XSAM3GeNGLKnsBpuijUow064sf7ww1nutC5/3w=="], + "zod": ["zod@3.25.67", "", {}, "sha512-idA2YXwpCdqUSKRCACDE6ItZD9TZzy3OZMtpfLoh6oPR47lipysRrJfjzMqFxQ3uJuUPyUeWe1r9vLH33xO/Qw=="], + "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.4.3", "", { "dependencies": { "@emnapi/wasi-threads": "1.0.2", "tslib": "^2.4.0" }, "bundled": true }, "sha512-4m62DuCE07lw01soJwPiBGC0nAww0Q+RY70VZ+n49yDIO13yyinhbWCeNnaob0lakDtWQzSdtNWzJeOJt2ma+g=="], "@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.4.3", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-pBPWdu6MLKROBX05wSNKcNb++m5Er+KQ9QkB+WVM+pW2Kx9hoSrVTnu3BdkI5eBLZoKu/J6mW/B6i6bJB2ytXQ=="], diff --git a/package.json b/package.json index 6569011..5041137 100644 --- a/package.json +++ b/package.json @@ -12,14 +12,19 @@ "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch" }, "devDependencies": { + "@biomejs/biome": "2.0.6", "@sveltejs/adapter-auto": "^6.0.0", "@sveltejs/kit": "^2.16.0", "@sveltejs/vite-plugin-svelte": "^5.0.0", "@tailwindcss/vite": "^4.0.0", + "@types/node": "^24.0.7", "svelte": "^5.0.0", "svelte-check": "^4.0.0", "tailwindcss": "^4.0.0", "typescript": "^5.0.0", "vite": "^6.2.6" + }, + "dependencies": { + "zod": "^3.25.67" } } diff --git a/src/lib/index.ts b/src/lib/index.ts index 589f439..2389e5f 100644 --- a/src/lib/index.ts +++ b/src/lib/index.ts @@ -1,10 +1,12 @@ // place files you want to import through the `$lib` alias in this folder. -type WLEDAPI = { - seg: Array<{ id: string, col: Array<[number, number, number]> }> -} +import { createTwitchClient } from "./twitch"; -const getAddr = (addr: string) => `http://${addr}/json/state` +type WLEDAPI = { + seg: Array<{ id: string; col: Array<[number, number, number]> }>; +}; + +const getAddr = (addr: string) => `http://${addr}/json/state`; const buildReq = (hex: string): WLEDAPI => { // hex is not validated. @@ -16,17 +18,25 @@ const buildReq = (hex: string): WLEDAPI => { const b = hexValues & 255; return { - seg: [{ - id: "0", - col: [[r, g, b]], - }] - } -} + seg: [ + { + id: "0", + col: [[r, g, b]], + }, + ], + }; +}; export const createLedClient = (addr: string) => { return { async setColor(hex: string) { - await fetch(getAddr(addr), { method: "POST", body: JSON.stringify(buildReq(hex)) }); - } - } -} + await fetch(getAddr(addr), { + method: "POST", + body: JSON.stringify(buildReq(hex)), + }); + }, + }; +}; + +// export const twitchClient = createTwitchClient(); +// twitchClient.subscribeToFollow(); diff --git a/src/lib/twitch.ts b/src/lib/twitch.ts new file mode 100644 index 0000000..cffcb70 --- /dev/null +++ b/src/lib/twitch.ts @@ -0,0 +1,90 @@ +import { HASH_SECRET, TWITCH_APP_CLIENT_ID, TWITCH_APP_SECRET } from "$env/static/private"; +import { z } from "zod"; + +const authBodyValidator = z.object({ + access_token: z.string(), + expires_in: z.number(), + token_type: z.enum(["bearer"]), +}); + +type TwitchSubscribeBody = { + type: "channel.follow"; + version: 2; + transport: { + method: "webhook"; + callback: string; + secret: string; + }; +}; + +// Not the complete event, but the information we need. +export const twitchFollowEvent = z.object({ + subscription: z.object({ + type: z.enum(['channel.follow']), + }), + event: z.object({ + user_name: z.string(), + }), +}); + +/* + *curl -X POST 'https://api.twitch.tv/helix/eventsub/subscriptions' \ +-H 'Authorization: Bearer 2gbdx6oar67tqtcmt49t3wpcgycthx' \ +-H 'Client-Id: wbmytr93xzw8zbg0p1izqyzzc5mbiz' \ +-H 'Content-Type: application/json' \ +-d '{"type":"channel.follow","version":"2","condition":{"broadcaster_user_id":"1234", "moderator_user_id": "1234"},"transport":{"method":"webhook","callback":"https://example.com/callback","secret":"s3cre77890ab"}}' + */ + +// Webhook -> Redeem of channel reward +export const createTwitchClient = () => { + const getAccessToken = async () => { + const twitchAuthResponse = await fetch( + `https://id.twitch.tv/oauth2/token?client_id=${TWITCH_APP_CLIENT_ID}&client_secret=${TWITCH_APP_SECRET}&grant_type=client_credentials`, + { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + }, + ); + + const { access_token } = authBodyValidator.parse( + await twitchAuthResponse.json(), + ); + + return access_token; + }; + + const _access_token = getAccessToken(); + + const subscribeToFollow = async () => { + const twitchSubResponse = await fetch( + "https://api.twitch.tv/helix/eventsub/subscriptions", + { + method: "POST", + headers: { + Authorization: `Bearer ${await _access_token}`, + "Client-Id": TWITCH_APP_CLIENT_ID, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + type: "channel.follow", + version: 2, + transport: { + callback: "http://localhost:5173/api/webhook", + method: "webhook", + secret: HASH_SECRET, + }, + } satisfies TwitchSubscribeBody), + }, + ); + + const res = await twitchSubResponse.json(); + + console.log(res); + }; + + return { + subscribeToFollow, + }; +}; diff --git a/src/routes/+page.server.ts b/src/routes/+page.server.ts index 329bd36..14f7ef4 100644 --- a/src/routes/+page.server.ts +++ b/src/routes/+page.server.ts @@ -1,7 +1,7 @@ import { createLedClient } from "$lib"; import { fail, type Actions } from "@sveltejs/kit"; -const client = createLedClient("192.168.1.215"); +export const client = createLedClient("192.168.1.215"); export const actions: Actions = { async default({ request }) { @@ -15,5 +15,5 @@ export const actions: Actions = { // TODO: validate actual HEX await client.setColor(color.toString()); - } -} + }, +}; diff --git a/src/routes/api/webhook/+server.ts b/src/routes/api/webhook/+server.ts new file mode 100644 index 0000000..23d9b8b --- /dev/null +++ b/src/routes/api/webhook/+server.ts @@ -0,0 +1,61 @@ +import { createHmac, timingSafeEqual } from "node:crypto"; +import type { RequestHandler } from "@sveltejs/kit"; +import { HASH_SECRET } from "$env/static/private"; +import { twitchFollowEvent } from "$lib/twitch"; +import { create } from "node:domain"; +import { createLedClient } from "$lib"; +import { client } from "../../+page.server"; + +const getHmacMessage = (request: Request, body: string): string => { + return ( + request.headers.get(TWITCH_MESSAGE_ID) + + request.headers.get(TWITCH_MESSAGE_TIMESTAMP) + body + ); +}; + +const getHmac = (secret: string, message: string): string => { + return createHmac("sha256", secret).update(message).digest("hex"); +}; + +const verifyMessage = (hmac: string, verifySignature: string): boolean => { + return timingSafeEqual(Buffer.from(hmac), Buffer.from(verifySignature)); +}; + +const HMAC_PREFIX = "sha256="; + +const TWITCH_MESSAGE_ID = "Twitch-Eventsub-Message-Id".toLowerCase(); +const TWITCH_MESSAGE_TIMESTAMP = + "Twitch-Eventsub-Message-Timestamp".toLowerCase(); +const TWITCH_MESSAGE_SIGNATURE = + "Twitch-Eventsub-Message-Signature".toLowerCase(); + +export const fallback: RequestHandler = async ({ request }) => { + const body = await request.text(); + + const message = getHmacMessage(request, body); + const hmac = HMAC_PREFIX + getHmac(HASH_SECRET, message); + + if (!verifyMessage(hmac, request.headers.get(TWITCH_MESSAGE_SIGNATURE)!)) { + console.log("Invalid request"); + throw new Error("go away"); + } else { + // Get JSON object from body, so you can process the message. + const jsonBody = JSON.parse(body); + + console.log(jsonBody); + const event = twitchFollowEvent.safeParse(jsonBody); + if (!event.success) { + console.log("Not a follow event"); + console.log(event.error); + return new Response(); + } + + const { user_name } = event.data.event; + + client.setColor("#FFFFFF"); + + // Handle notification... + console.log(`${user_name} has just subscribed`); + return new Response(); + } +};