diff --git a/.claude/skills/setup-agent-team/reddit-fetch.ts b/.claude/skills/setup-agent-team/reddit-fetch.ts index a732e629..838cf573 100644 --- a/.claude/skills/setup-agent-team/reddit-fetch.ts +++ b/.claude/skills/setup-agent-team/reddit-fetch.ts @@ -10,6 +10,37 @@ import { Database } from "bun:sqlite"; import { existsSync } from "node:fs"; +import * as v from "valibot"; + +/** Valibot schemas for Reddit API responses. */ +const RedditTokenSchema = v.object({ + access_token: v.string(), +}); + +const RedditChildDataSchema = v.looseObject({ + name: v.pipe(v.unknown(), v.transform((x) => String(x ?? ""))), + title: v.pipe(v.unknown(), v.transform((x) => String(x ?? ""))), + permalink: v.pipe(v.unknown(), v.transform((x) => String(x ?? ""))), + subreddit: v.pipe(v.unknown(), v.transform((x) => String(x ?? ""))), + score: v.pipe(v.unknown(), v.transform((x) => Number(x ?? 0))), + num_comments: v.pipe(v.unknown(), v.transform((x) => Number(x ?? 0))), + created_utc: v.pipe(v.unknown(), v.transform((x) => Number(x ?? 0))), + selftext: v.pipe(v.unknown(), v.transform((x) => String(x ?? ""))), + author: v.pipe(v.unknown(), v.transform((x) => String(x ?? ""))), +}); + +const RedditListingSchema = v.object({ + data: v.object({ + children: v.array(v.object({ + data: RedditChildDataSchema, + })), + }), +}); + +const RedditCommentDataSchema = v.looseObject({ + body: v.pipe(v.unknown(), v.transform((x) => String(x ?? ""))), + subreddit: v.pipe(v.unknown(), v.transform((x) => String(x ?? ""))), +}); const CLIENT_ID = process.env.REDDIT_CLIENT_ID ?? ""; const CLIENT_SECRET = process.env.REDDIT_CLIENT_SECRET ?? ""; @@ -156,13 +187,13 @@ async function getToken(): Promise { }, body: `grant_type=password&username=${encodeURIComponent(USERNAME)}&password=${encodeURIComponent(PASSWORD)}`, }); - const data = (await res.json()) as Record; - const token = typeof data.access_token === "string" ? data.access_token : ""; - if (!token) { - console.error("Reddit auth failed:", JSON.stringify(data)); + const json: unknown = await res.json(); + const parsed = v.safeParse(RedditTokenSchema, json); + if (!parsed.success) { + console.error("Reddit auth failed:", JSON.stringify(json)); process.exit(1); } - return token; + return parsed.output.access_token; } /** Fetch a Reddit API endpoint with auth. */ @@ -183,29 +214,23 @@ async function redditGet(token: string, path: string): Promise { /** Extract posts from a Reddit listing response. */ function extractPosts(data: unknown): Map { const posts = new Map(); - if (!data || typeof data !== "object") return posts; - const listing = data as Record; - const listingData = listing.data as Record | undefined; - const children = listingData?.children; - if (!Array.isArray(children)) return posts; + const parsed = v.safeParse(RedditListingSchema, data); + if (!parsed.success) return posts; - for (const child of children) { - const c = child as Record; - const d = c.data as Record | undefined; - if (!d) continue; - const id = String(d.name ?? ""); - if (!id || posts.has(id)) continue; + for (const child of parsed.output.data.children) { + const d = child.data; + if (!d.name || posts.has(d.name)) continue; - posts.set(id, { - title: String(d.title ?? ""), - permalink: String(d.permalink ?? ""), - subreddit: String(d.subreddit ?? ""), - postId: id, - score: Number(d.score ?? 0), - numComments: Number(d.num_comments ?? 0), - createdUtc: Number(d.created_utc ?? 0), - selftext: String(d.selftext ?? "").slice(0, 2000), - authorName: String(d.author ?? ""), + posts.set(d.name, { + title: d.title, + permalink: d.permalink, + subreddit: d.subreddit, + postId: d.name, + score: d.score, + numComments: d.num_comments, + createdUtc: d.created_utc, + selftext: d.selftext.slice(0, 2000), + authorName: d.author, authorComments: [], }); } @@ -221,18 +246,15 @@ async function fetchUserComments(token: string, username: string): Promise; - const listingData = listing.data as Record | undefined; - const children = listingData?.children; - if (!Array.isArray(children)) return []; + const parsed = v.safeParse(RedditListingSchema, data); + if (!parsed.success) return []; - return children + return parsed.output.data.children .map((child) => { - const c = child as Record; - const d = c.data as Record | undefined; - const body = String(d?.body ?? "").slice(0, 500); - const sub = String(d?.subreddit ?? ""); + const cp = v.safeParse(RedditCommentDataSchema, child.data); + if (!cp.success) return ""; + const body = cp.output.body.slice(0, 500); + const sub = cp.output.subreddit; return sub ? `[r/${sub}] ${body}` : body; }) .filter(Boolean); diff --git a/.claude/skills/setup-spa/main.ts b/.claude/skills/setup-spa/main.ts index 103d5d80..de410fb4 100644 --- a/.claude/skills/setup-spa/main.ts +++ b/.claude/skills/setup-spa/main.ts @@ -1549,8 +1549,9 @@ async function getRedditToken(): Promise { }, body: `grant_type=password&username=${encodeURIComponent(REDDIT_USERNAME)}&password=${encodeURIComponent(REDDIT_PASSWORD)}`, }); - const data = (await res.json()) as Record; - return typeof data.access_token === "string" ? data.access_token : null; + const json: unknown = await res.json(); + const parsed = v.safeParse(v.object({ access_token: v.string() }), json); + return parsed.success ? parsed.output.access_token : null; } /** Post a reply to a Reddit thread. Returns the comment URL or an error. */ @@ -1578,10 +1579,11 @@ async function postRedditReply(postId: string, replyText: string): Promise; + const json: unknown = await res.json(); if (!res.ok) { - const errMsg = typeof data.message === "string" ? data.message : `HTTP ${res.status}`; + const errParsed = v.safeParse(v.object({ message: v.string() }), json); + const errMsg = errParsed.success ? errParsed.output.message : `HTTP ${res.status}`; console.error(`[spa] Reddit reply failed: ${errMsg}`); return Response.json( { @@ -1594,19 +1596,24 @@ async function postRedditReply(postId: string, replyText: string): Promise= 4 && Array.isArray(item[3])) { for (const inner of item[3]) { - const rec = inner as Record | undefined; - if (rec && typeof rec.data === "object" && rec.data !== null) { - const d = rec.data as Record; - if (typeof d.permalink === "string") { - commentUrl = `https://reddit.com${d.permalink}`; - } + const innerParsed = v.safeParse(JqueryInnerSchema, inner); + if (innerParsed.success) { + commentUrl = `https://reddit.com${innerParsed.output.data.permalink}`; } } }