Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
98 changes: 60 additions & 38 deletions .claude/skills/setup-agent-team/reddit-fetch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ?? "";
Expand Down Expand Up @@ -156,13 +187,13 @@ async function getToken(): Promise<string> {
},
body: `grant_type=password&username=${encodeURIComponent(USERNAME)}&password=${encodeURIComponent(PASSWORD)}`,
});
const data = (await res.json()) as Record<string, unknown>;
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. */
Expand All @@ -183,29 +214,23 @@ async function redditGet(token: string, path: string): Promise<unknown> {
/** Extract posts from a Reddit listing response. */
function extractPosts(data: unknown): Map<string, RedditPost> {
const posts = new Map<string, RedditPost>();
if (!data || typeof data !== "object") return posts;
const listing = data as Record<string, unknown>;
const listingData = listing.data as Record<string, unknown> | undefined;
const children = listingData?.children;
if (!Array.isArray(children)) return posts;

for (const child of children) {
const c = child as Record<string, unknown>;
const d = c.data as Record<string, unknown> | undefined;
if (!d) continue;
const id = String(d.name ?? "");
if (!id || posts.has(id)) 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 ?? ""),
const parsed = v.safeParse(RedditListingSchema, data);
if (!parsed.success) return posts;

for (const child of parsed.output.data.children) {
const d = child.data;
if (!d.name || posts.has(d.name)) continue;

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: [],
});
}
Expand All @@ -221,18 +246,15 @@ async function fetchUserComments(token: string, username: string): Promise<strin
// depth.
if (!REDDIT_USERNAME_RE.test(username)) return [];
const data = await redditGet(token, `/user/${encodeURIComponent(username)}/comments?limit=25&sort=new`);
if (!data || typeof data !== "object") return [];
const listing = data as Record<string, unknown>;
const listingData = listing.data as Record<string, unknown> | 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<string, unknown>;
const d = c.data as Record<string, unknown> | 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);
Expand Down
35 changes: 21 additions & 14 deletions .claude/skills/setup-spa/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1549,8 +1549,9 @@ async function getRedditToken(): Promise<string | null> {
},
body: `grant_type=password&username=${encodeURIComponent(REDDIT_USERNAME)}&password=${encodeURIComponent(REDDIT_PASSWORD)}`,
});
const data = (await res.json()) as Record<string, unknown>;
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. */
Expand Down Expand Up @@ -1578,10 +1579,11 @@ async function postRedditReply(postId: string, replyText: string): Promise<Respo
body: `thing_id=${encodeURIComponent(postId)}&text=${encodeURIComponent(replyText)}`,
});

const data = (await res.json()) as Record<string, unknown>;
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(
{
Expand All @@ -1594,19 +1596,24 @@ async function postRedditReply(postId: string, replyText: string): Promise<Respo
);
}

// Extract the comment URL from the response
const jquery = data.jquery as unknown[] | undefined;
// Reddit's legacy "comment" endpoint returns a jQuery-style response.
// Extract the permalink from nested arrays: jquery[n][3][m].data.permalink
const JqueryCommentSchema = v.object({
jquery: v.array(v.unknown()),
});
const JqueryInnerSchema = v.object({
data: v.object({ permalink: v.string() }),
});

let commentUrl = "";
if (Array.isArray(jquery)) {
for (const item of jquery) {
const jqParsed = v.safeParse(JqueryCommentSchema, json);
if (jqParsed.success) {
for (const item of jqParsed.output.jquery) {
if (Array.isArray(item) && item.length >= 4 && Array.isArray(item[3])) {
for (const inner of item[3]) {
const rec = inner as Record<string, unknown> | undefined;
if (rec && typeof rec.data === "object" && rec.data !== null) {
const d = rec.data as Record<string, unknown>;
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}`;
}
}
}
Expand Down
Loading