feat: user following

This commit is contained in:
2025-06-30 18:09:40 +01:00
parent 7fa4454150
commit 40410b1412
7 changed files with 246 additions and 17 deletions

34
biome.json Normal file
View File

@ -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"
}
}
}
}

View File

@ -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=="],

View File

@ -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"
}
}

View File

@ -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: [{
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();

90
src/lib/twitch.ts Normal file
View File

@ -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,
};
};

View File

@ -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());
}
}
},
};

View File

@ -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();
}
};