From d1ffb0a997480ff3bf5553a0fd5186dc6d9cd4ce Mon Sep 17 00:00:00 2001 From: zlei9 Date: Sun, 29 Mar 2026 14:30:40 +0800 Subject: [PATCH] Initial commit with translated description --- README.md | 154 +++++++++++++ SKILL.md | 103 +++++++++ _meta.json | 6 + scripts/reddit.mjs | 549 +++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 812 insertions(+) create mode 100644 README.md create mode 100644 SKILL.md create mode 100644 _meta.json create mode 100644 scripts/reddit.mjs diff --git a/README.md b/README.md new file mode 100644 index 0000000..2d8df11 --- /dev/null +++ b/README.md @@ -0,0 +1,154 @@ +# Reddit Skill for Clawdbot + +Browse, search, post to, and moderate any subreddit from your agent. + +## Quick Start + +**Read-only** (no setup needed): +```bash +node scripts/reddit.mjs posts news --limit 5 +node scripts/reddit.mjs search all "breaking news" +``` + +**Posting & Moderation** (requires OAuth): +1. Create a Reddit app at https://www.reddit.com/prefs/apps +2. Set environment variables (see Setup below) +3. Run `node scripts/reddit.mjs login` once to authorize + +--- + +## Setup for Posting/Moderation + +### 1. Create a Reddit App + +1. Go to https://www.reddit.com/prefs/apps +2. Scroll down and click **"create another app..."** +3. Fill in: + - **name**: anything (e.g., "clawdbot") + - **type**: select **script** + - **redirect uri**: `http://localhost:8080/callback` +4. Click **Create app** +5. Note your: + - **Client ID** — the string under your app name + - **Client Secret** — labeled "secret" + +### 2. Set Environment Variables + +Add these to your shell profile or Clawdbot's environment: + +```bash +export REDDIT_CLIENT_ID="your_client_id" +export REDDIT_CLIENT_SECRET="your_client_secret" +export REDDIT_USERNAME="your_reddit_username" +export REDDIT_PASSWORD="your_reddit_password" +``` + +### 3. Authorize (One Time) + +```bash +node scripts/reddit.mjs login +``` + +This opens a browser for OAuth. After authorizing, a token is saved to `~/.reddit-token.json` and auto-refreshes. + +--- + +## Personalizing the Skill + +The `SKILL.md` file tells your agent how to use this skill. You'll want to customize it for your setup: + +### Update the Examples + +Replace the generic subreddit names (`wallstreetbets`, `yoursubreddit`) with the ones you actually use: + +```markdown +# Before +node {baseDir}/scripts/reddit.mjs posts wallstreetbets + +# After +node {baseDir}/scripts/reddit.mjs posts mysubreddit +``` + +### Add Your Subreddits to the Notes + +At the bottom of `SKILL.md`, add a section listing your subreddits: + +```markdown +## My Subreddits + +- **r/mysubreddit** — I'm a mod here (full access) +- **r/interestingtopic** — I follow this one +- **r/anotherone** — Read-only +``` + +This helps your agent know what it can do where. + +### Customize the User-Agent (Optional) + +In `scripts/reddit.mjs`, you can personalize the User-Agent string: + +```javascript +// Find this line near the top: +const USER_AGENT = 'script:clawdbot-reddit:v1.0.0'; + +// Change to something like: +const USER_AGENT = 'script:my-reddit-bot:v1.0.0 (by /u/your_username)'; +``` + +Reddit recommends including your username so they can contact you if needed. + +--- + +## Commands Reference + +| Command | Auth Required | Description | +|---------|---------------|-------------| +| `posts ` | No | Get hot/new/top posts | +| `search ` | No | Search posts | +| `comments ` | No | Get comments on a post | +| `submit --title "..." --text "..."` | Yes | Create a text post | +| `submit --title "..." --url "..."` | Yes | Create a link post | +| `reply "text"` | Yes | Reply to a post or comment | +| `mod remove ` | Yes + Mod | Remove post/comment | +| `mod approve ` | Yes + Mod | Approve post/comment | +| `mod sticky ` | Yes + Mod | Sticky a post | +| `mod queue ` | Yes + Mod | View mod queue | +| `login` | — | Start OAuth flow | +| `whoami` | Yes | Check logged-in user | + +### Options + +- `--sort hot|new|top|controversial` — Sort order for posts +- `--time day|week|month|year|all` — Time filter for top/controversial +- `--limit N` — Number of results (default: 25) + +--- + +## Rate Limits + +- **With OAuth**: ~60 requests/minute +- **Without OAuth**: ~10 requests/minute + +The skill handles token refresh automatically. + +--- + +## Troubleshooting + +**"Missing REDDIT_CLIENT_ID or REDDIT_CLIENT_SECRET"** +→ Environment variables aren't set. Check your shell profile or Clawdbot config. + +**"Not logged in. Run: node reddit.mjs login"** +→ You need to authorize first. Run the login command. + +**"Reddit returned HTML instead of JSON"** +→ Reddit sometimes does this under load. Wait a moment and try again. + +**Token file location**: `~/.reddit-token.json` +→ Delete this file to force re-authorization. + +--- + +## License + +MIT — do whatever you want with it. diff --git a/SKILL.md b/SKILL.md new file mode 100644 index 0000000..cab844f --- /dev/null +++ b/SKILL.md @@ -0,0 +1,103 @@ +--- +name: reddit +description: "浏览、搜索、发布和管理Reddit。无需身份验证即可只读;发布/管理需要OAuth设置。" +metadata: {"clawdbot":{"emoji":"📣","requires":{"bins":["node"]}}} +--- + +# Reddit + +Browse, search, post to, and moderate subreddits. Read-only actions work without auth; posting/moderation requires OAuth setup. + +## Setup (for posting/moderation) + +1. Go to https://www.reddit.com/prefs/apps +2. Click "create another app..." +3. Select "script" type +4. Set redirect URI to `http://localhost:8080` +5. Note your client ID (under app name) and client secret +6. Set environment variables: + ```bash + export REDDIT_CLIENT_ID="your_client_id" + export REDDIT_CLIENT_SECRET="your_client_secret" + export REDDIT_USERNAME="your_username" + export REDDIT_PASSWORD="your_password" + ``` + +## Read Posts (no auth required) + +```bash +# Hot posts from a subreddit +node {baseDir}/scripts/reddit.mjs posts wallstreetbets + +# New posts +node {baseDir}/scripts/reddit.mjs posts wallstreetbets --sort new + +# Top posts (day/week/month/year/all) +node {baseDir}/scripts/reddit.mjs posts wallstreetbets --sort top --time week + +# Limit results +node {baseDir}/scripts/reddit.mjs posts wallstreetbets --limit 5 +``` + +## Search Posts + +```bash +# Search within a subreddit +node {baseDir}/scripts/reddit.mjs search wallstreetbets "YOLO" + +# Search all of Reddit +node {baseDir}/scripts/reddit.mjs search all "stock picks" +``` + +## Get Comments on a Post + +```bash +# By post ID or full URL +node {baseDir}/scripts/reddit.mjs comments POST_ID +node {baseDir}/scripts/reddit.mjs comments "https://reddit.com/r/subreddit/comments/abc123/..." +``` + +## Submit a Post (requires auth) + +```bash +# Text post +node {baseDir}/scripts/reddit.mjs submit yoursubreddit --title "Weekly Discussion" --text "What's on your mind?" + +# Link post +node {baseDir}/scripts/reddit.mjs submit yoursubreddit --title "Great article" --url "https://example.com/article" +``` + +## Reply to a Post/Comment (requires auth) + +```bash +node {baseDir}/scripts/reddit.mjs reply THING_ID "Your reply text here" +``` + +## Moderation (requires auth + mod permissions) + +```bash +# Remove a post/comment +node {baseDir}/scripts/reddit.mjs mod remove THING_ID + +# Approve a post/comment +node {baseDir}/scripts/reddit.mjs mod approve THING_ID + +# Sticky a post +node {baseDir}/scripts/reddit.mjs mod sticky POST_ID + +# Unsticky +node {baseDir}/scripts/reddit.mjs mod unsticky POST_ID + +# Lock comments +node {baseDir}/scripts/reddit.mjs mod lock POST_ID + +# View modqueue +node {baseDir}/scripts/reddit.mjs mod queue yoursubreddit +``` + +## Notes + +- Read actions use Reddit's public JSON API (no auth needed) +- Post/mod actions require OAuth - run `login` command once to authorize +- Token stored at `~/.reddit-token.json` (auto-refreshes) +- Rate limits: ~60 requests/minute for OAuth, ~10/minute for unauthenticated diff --git a/_meta.json b/_meta.json new file mode 100644 index 0000000..425aa4f --- /dev/null +++ b/_meta.json @@ -0,0 +1,6 @@ +{ + "ownerId": "kn77kg8v670jkmnjb96amp5ywd7yjr0k", + "slug": "reddit", + "version": "1.0.0", + "publishedAt": 1767605786690 +} \ No newline at end of file diff --git a/scripts/reddit.mjs b/scripts/reddit.mjs new file mode 100644 index 0000000..ef0f24e --- /dev/null +++ b/scripts/reddit.mjs @@ -0,0 +1,549 @@ +#!/usr/bin/env node + +/** + * Reddit CLI - Browse, search, post, and moderate subreddits + * + * Read-only: Uses public JSON API (no auth) + * Write/Mod: Requires OAuth - run `login` command first + */ + +import { createServer } from 'http'; +import { readFileSync, writeFileSync, existsSync } from 'fs'; +import { homedir } from 'os'; +import { join } from 'path'; +import { exec } from 'child_process'; + +const BASE_URL = 'https://www.reddit.com'; +const OAUTH_URL = 'https://oauth.reddit.com'; +const USER_AGENT = 'script:clawdbot-reddit:v1.0.0'; +const TOKEN_FILE = join(homedir(), '.reddit-token.json'); +const REDIRECT_URI = 'http://localhost:8080/callback'; +const SCOPES = 'read submit edit identity mysubreddits modposts modcontributors modmail modconfig modlog modself flair'; + +let tokenCache = null; + +function getClientCreds() { + const { REDDIT_CLIENT_ID, REDDIT_CLIENT_SECRET } = process.env; + if (!REDDIT_CLIENT_ID || !REDDIT_CLIENT_SECRET) { + throw new Error('Missing REDDIT_CLIENT_ID or REDDIT_CLIENT_SECRET'); + } + return { clientId: REDDIT_CLIENT_ID, clientSecret: REDDIT_CLIENT_SECRET }; +} + +function loadToken() { + if (tokenCache) return tokenCache; + if (!existsSync(TOKEN_FILE)) return null; + try { + tokenCache = JSON.parse(readFileSync(TOKEN_FILE, 'utf-8')); + return tokenCache; + } catch { + return null; + } +} + +function saveToken(token) { + tokenCache = token; + writeFileSync(TOKEN_FILE, JSON.stringify(token, null, 2)); +} + +async function refreshAccessToken() { + const token = loadToken(); + if (!token?.refresh_token) { + throw new Error('Not logged in. Run: node reddit.mjs login'); + } + + const { clientId, clientSecret } = getClientCreds(); + const auth = Buffer.from(`${clientId}:${clientSecret}`).toString('base64'); + + const res = await fetch('https://www.reddit.com/api/v1/access_token', { + method: 'POST', + headers: { + 'Authorization': `Basic ${auth}`, + 'Content-Type': 'application/x-www-form-urlencoded', + 'User-Agent': USER_AGENT, + }, + body: new URLSearchParams({ + grant_type: 'refresh_token', + refresh_token: token.refresh_token, + }), + }); + + if (!res.ok) { + throw new Error(`Token refresh failed: ${res.status} ${await res.text()}`); + } + + const data = await res.json(); + const newToken = { + access_token: data.access_token, + refresh_token: token.refresh_token, // Reddit doesn't always return a new refresh token + expires_at: Date.now() + (data.expires_in * 1000), + }; + saveToken(newToken); + return newToken.access_token; +} + +async function getAccessToken() { + const token = loadToken(); + if (!token) { + throw new Error('Not logged in. Run: node reddit.mjs login'); + } + + // Refresh if expired or expiring soon (5 min buffer) + if (Date.now() > (token.expires_at - 300000)) { + return refreshAccessToken(); + } + + return token.access_token; +} + +async function publicFetch(path) { + // Insert .json before query string if present + let url; + if (path.includes('?')) { + const [basePath, query] = path.split('?'); + url = `${BASE_URL}${basePath}.json?${query}`; + } else { + url = `${BASE_URL}${path}.json`; + } + + const res = await fetch(url, { + headers: { + 'User-Agent': USER_AGENT, + 'Accept': 'application/json', + }, + }); + + const text = await res.text(); + + if (!res.ok) { + throw new Error(`Request failed: ${res.status} ${text.slice(0, 200)}`); + } + + // Check if we got HTML instead of JSON (Reddit sometimes does this) + if (text.trim().startsWith('<')) { + throw new Error('Reddit returned HTML instead of JSON. Try again in a moment.'); + } + + return JSON.parse(text); +} + +async function oauthFetch(path, options = {}) { + const token = await getAccessToken(); + const url = `${OAUTH_URL}${path}`; + + const res = await fetch(url, { + ...options, + headers: { + 'Authorization': `Bearer ${token}`, + 'User-Agent': USER_AGENT, + ...options.headers, + }, + }); + + if (!res.ok) { + const text = await res.text(); + throw new Error(`Request failed: ${res.status} ${text.slice(0, 500)}`); + } + + return res.json(); +} + +async function oauthPost(path, data) { + return oauthFetch(path, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams(data), + }); +} + +// OAuth Login Flow + +async function login() { + const { clientId, clientSecret } = getClientCreds(); + + const state = Math.random().toString(36).slice(2); + const authUrl = `https://www.reddit.com/api/v1/authorize?client_id=${clientId}&response_type=code&state=${state}&redirect_uri=${encodeURIComponent(REDIRECT_URI)}&duration=permanent&scope=${encodeURIComponent(SCOPES)}`; + + console.log('\n🔐 Reddit OAuth Login\n'); + console.log('Open this URL in your browser:\n'); + console.log(authUrl); + console.log('\nWaiting for authorization...\n'); + + // Start local server to catch the callback + return new Promise((resolve, reject) => { + const server = createServer(async (req, res) => { + if (!req.url.startsWith('/callback')) { + res.writeHead(404); + res.end('Not found'); + return; + } + + const url = new URL(req.url, 'http://localhost:8080'); + const code = url.searchParams.get('code'); + const returnedState = url.searchParams.get('state'); + const error = url.searchParams.get('error'); + + if (error) { + res.writeHead(400); + res.end(`Authorization failed: ${error}`); + server.close(); + reject(new Error(`Authorization failed: ${error}`)); + return; + } + + if (returnedState !== state) { + res.writeHead(400); + res.end('State mismatch - possible CSRF attack'); + server.close(); + reject(new Error('State mismatch')); + return; + } + + // Exchange code for token + const auth = Buffer.from(`${clientId}:${clientSecret}`).toString('base64'); + + try { + const tokenRes = await fetch('https://www.reddit.com/api/v1/access_token', { + method: 'POST', + headers: { + 'Authorization': `Basic ${auth}`, + 'Content-Type': 'application/x-www-form-urlencoded', + 'User-Agent': USER_AGENT, + }, + body: new URLSearchParams({ + grant_type: 'authorization_code', + code, + redirect_uri: REDIRECT_URI, + }), + }); + + if (!tokenRes.ok) { + throw new Error(`Token exchange failed: ${await tokenRes.text()}`); + } + + const tokenData = await tokenRes.json(); + + const token = { + access_token: tokenData.access_token, + refresh_token: tokenData.refresh_token, + expires_at: Date.now() + (tokenData.expires_in * 1000), + }; + + saveToken(token); + + res.writeHead(200, { 'Content-Type': 'text/html' }); + res.end('

✅ Logged in!

You can close this tab.

'); + + console.log('✅ Successfully logged in! Token saved to ~/.reddit-token.json\n'); + + server.close(); + resolve(); + } catch (err) { + res.writeHead(500); + res.end(`Error: ${err.message}`); + server.close(); + reject(err); + } + }); + + server.listen(8080, () => { + // Try to open browser automatically + const cmd = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open'; + exec(`${cmd} "${authUrl}"`); + }); + + // Timeout after 5 minutes + setTimeout(() => { + server.close(); + reject(new Error('Login timed out')); + }, 300000); + }); +} + +// Commands + +async function getPosts(subreddit, { sort = 'hot', time = 'day', limit = 25 }) { + const timeSuffix = sort === 'top' || sort === 'controversial' ? `?t=${time}&limit=${limit}` : `?limit=${limit}`; + const data = await publicFetch(`/r/${subreddit}/${sort}${timeSuffix}`); + + const posts = data.data.children.map(p => ({ + id: p.data.id, + title: p.data.title, + author: p.data.author, + score: p.data.score, + comments: p.data.num_comments, + url: p.data.url, + permalink: `https://reddit.com${p.data.permalink}`, + created: new Date(p.data.created_utc * 1000).toISOString(), + selftext: p.data.selftext?.slice(0, 500) || null, + flair: p.data.link_flair_text || null, + })); + + console.log(JSON.stringify(posts, null, 2)); +} + +async function searchPosts(subreddit, query, { sort = 'relevance', time = 'all', limit = 25 }) { + const path = subreddit === 'all' + ? `/search?q=${encodeURIComponent(query)}&sort=${sort}&t=${time}&limit=${limit}` + : `/r/${subreddit}/search?q=${encodeURIComponent(query)}&restrict_sr=on&sort=${sort}&t=${time}&limit=${limit}`; + + const data = await publicFetch(path); + + const posts = data.data.children.map(p => ({ + id: p.data.id, + subreddit: p.data.subreddit, + title: p.data.title, + author: p.data.author, + score: p.data.score, + comments: p.data.num_comments, + permalink: `https://reddit.com${p.data.permalink}`, + created: new Date(p.data.created_utc * 1000).toISOString(), + })); + + console.log(JSON.stringify(posts, null, 2)); +} + +async function getComments(postId, { limit = 50 }) { + // Handle full URLs + if (postId.startsWith('http')) { + const match = postId.match(/comments\/([a-z0-9]+)/i); + if (match) postId = match[1]; + } + + // Need to find the subreddit - fetch post info first + const postData = await publicFetch(`/by_id/t3_${postId}`); + const subreddit = postData.data.children[0]?.data?.subreddit; + + if (!subreddit) throw new Error('Could not find post'); + + const data = await publicFetch(`/r/${subreddit}/comments/${postId}?limit=${limit}`); + + function parseComments(children, depth = 0) { + const results = []; + for (const c of children) { + if (c.kind !== 't1') continue; + results.push({ + id: c.data.id, + author: c.data.author, + body: c.data.body?.slice(0, 1000), + score: c.data.score, + depth, + created: new Date(c.data.created_utc * 1000).toISOString(), + }); + if (c.data.replies?.data?.children) { + results.push(...parseComments(c.data.replies.data.children, depth + 1)); + } + } + return results; + } + + const comments = parseComments(data[1].data.children); + console.log(JSON.stringify(comments, null, 2)); +} + +async function submitPost(subreddit, { title, text, url }) { + const data = { + sr: subreddit, + title, + kind: url ? 'link' : 'self', + api_type: 'json', + }; + + if (url) data.url = url; + if (text) data.text = text; + + const result = await oauthPost('/api/submit', data); + + if (result.json?.errors?.length) { + throw new Error(`Submit failed: ${JSON.stringify(result.json.errors)}`); + } + + console.log(JSON.stringify({ + success: true, + url: result.json?.data?.url, + id: result.json?.data?.id, + }, null, 2)); +} + +async function reply(thingId, text) { + // Ensure proper prefix + if (!thingId.startsWith('t1_') && !thingId.startsWith('t3_')) { + // Guess based on length - posts are usually shorter IDs + thingId = `t1_${thingId}`; // Assume comment by default + } + + const result = await oauthPost('/api/comment', { + thing_id: thingId, + text, + api_type: 'json', + }); + + if (result.json?.errors?.length) { + throw new Error(`Reply failed: ${JSON.stringify(result.json.errors)}`); + } + + console.log(JSON.stringify({ + success: true, + id: result.json?.data?.things?.[0]?.data?.id, + }, null, 2)); +} + +async function modAction(action, thingId, subreddit) { + // Ensure proper prefix for IDs + if (thingId && !thingId.startsWith('t1_') && !thingId.startsWith('t3_')) { + thingId = `t3_${thingId}`; // Assume post + } + + switch (action) { + case 'remove': + await oauthPost('/api/remove', { id: thingId, spam: false }); + console.log(JSON.stringify({ success: true, action: 'removed', id: thingId })); + break; + + case 'approve': + await oauthPost('/api/approve', { id: thingId }); + console.log(JSON.stringify({ success: true, action: 'approved', id: thingId })); + break; + + case 'sticky': + await oauthPost('/api/set_subreddit_sticky', { id: thingId, state: true }); + console.log(JSON.stringify({ success: true, action: 'stickied', id: thingId })); + break; + + case 'unsticky': + await oauthPost('/api/set_subreddit_sticky', { id: thingId, state: false }); + console.log(JSON.stringify({ success: true, action: 'unstickied', id: thingId })); + break; + + case 'lock': + await oauthPost('/api/lock', { id: thingId }); + console.log(JSON.stringify({ success: true, action: 'locked', id: thingId })); + break; + + case 'unlock': + await oauthPost('/api/unlock', { id: thingId }); + console.log(JSON.stringify({ success: true, action: 'unlocked', id: thingId })); + break; + + case 'queue': + if (!subreddit) throw new Error('Subreddit required for modqueue'); + const data = await oauthFetch(`/r/${subreddit}/about/modqueue?limit=25`); + const items = data.data.children.map(p => ({ + id: p.data.id, + type: p.kind === 't1' ? 'comment' : 'post', + author: p.data.author, + title: p.data.title || p.data.body?.slice(0, 100), + reports: p.data.num_reports, + created: new Date(p.data.created_utc * 1000).toISOString(), + })); + console.log(JSON.stringify(items, null, 2)); + break; + + default: + throw new Error(`Unknown mod action: ${action}`); + } +} + +async function whoami() { + const data = await oauthFetch('/api/v1/me'); + console.log(JSON.stringify({ + username: data.name, + id: data.id, + karma: data.total_karma, + created: new Date(data.created_utc * 1000).toISOString(), + }, null, 2)); +} + +// CLI Parser + +function parseArgs(args) { + const result = { _: [] }; + for (let i = 0; i < args.length; i++) { + const arg = args[i]; + if (arg.startsWith('--')) { + const key = arg.slice(2); + const next = args[i + 1]; + if (next && !next.startsWith('--')) { + result[key] = next; + i++; + } else { + result[key] = true; + } + } else { + result._.push(arg); + } + } + return result; +} + +async function main() { + const args = parseArgs(process.argv.slice(2)); + const [command, ...rest] = args._; + + try { + switch (command) { + case 'login': { + await login(); + break; + } + + case 'whoami': { + await whoami(); + break; + } + + case 'posts': { + const [subreddit] = rest; + if (!subreddit) throw new Error('Usage: posts [--sort hot|new|top] [--time day|week|month|year|all] [--limit N]'); + await getPosts(subreddit, args); + break; + } + + case 'search': { + const [subreddit, ...queryParts] = rest; + const query = queryParts.join(' '); + if (!subreddit || !query) throw new Error('Usage: search [--sort relevance|top|new] [--time all|day|week|month|year] [--limit N]'); + await searchPosts(subreddit, query, args); + break; + } + + case 'comments': { + const [postId] = rest; + if (!postId) throw new Error('Usage: comments [--limit N]'); + await getComments(postId, args); + break; + } + + case 'submit': { + const [subreddit] = rest; + if (!subreddit || !args.title) throw new Error('Usage: submit --title "Title" [--text "Body"] [--url "URL"]'); + await submitPost(subreddit, args); + break; + } + + case 'reply': { + const [thingId, ...textParts] = rest; + const text = textParts.join(' '); + if (!thingId || !text) throw new Error('Usage: reply '); + await reply(thingId, text); + break; + } + + case 'mod': { + const [action, targetOrSubreddit] = rest; + if (!action) throw new Error('Usage: mod '); + await modAction(action, targetOrSubreddit, targetOrSubreddit); + break; + } + + default: + console.error(`Commands: login, whoami, posts, search, comments, submit, reply, mod`); + process.exit(1); + } + } catch (err) { + console.error('Error:', err.message); + process.exit(1); + } +} + +main();