feat: unified feed bots for discord
combines reddit digest, github trending, new ai repos, and claude code release tracking into one CLI tool. usage: bun run feed <reddit|trending|new-repos|claude-releases|all>
This commit is contained in:
commit
bb90d15209
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
node_modules/
|
||||||
|
bun.lock
|
||||||
|
.last-version
|
||||||
12
package.json
Normal file
12
package.json
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"name": "discord-feed-bots",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Discord feed bots: reddit digest, github trending, new AI repos, claude-code releases",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"feed": "bun run src/index.ts"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/bun": "latest"
|
||||||
|
}
|
||||||
|
}
|
||||||
54
src/config.ts
Normal file
54
src/config.ts
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
// --- Reddit Digest ---
|
||||||
|
export const REDDIT_SUBREDDITS = [
|
||||||
|
'LocalLLaMA',
|
||||||
|
'MachineLearning',
|
||||||
|
'ClaudeAI',
|
||||||
|
'ChatGPT',
|
||||||
|
'artificial',
|
||||||
|
'LangChain',
|
||||||
|
'AutoGPT',
|
||||||
|
];
|
||||||
|
|
||||||
|
export const REDDIT_LISTINGS = ['hot', 'rising'] as const;
|
||||||
|
export const REDDIT_MIN_SCORE = 50;
|
||||||
|
export const REDDIT_MAX_AGE_HOURS = 24;
|
||||||
|
export const REDDIT_MAX_POSTS_PER_SUB = 10;
|
||||||
|
export const REDDIT_SNIPPET_LENGTH = 200;
|
||||||
|
export const REDDIT_REQUEST_DELAY_MS = 1500;
|
||||||
|
export const REDDIT_POSTS_PER_PAGE = 100;
|
||||||
|
export const REDDIT_USER_AGENT =
|
||||||
|
'discord-feed-bots/1.0 (reddit digest bot)';
|
||||||
|
|
||||||
|
// --- GitHub Trending ---
|
||||||
|
export const TRENDING_QUERIES = [
|
||||||
|
'topic:ai-agent topic:llm-agent topic:ai-agents',
|
||||||
|
'"agent framework" language:python,typescript',
|
||||||
|
'"autonomous agent" stars:>50',
|
||||||
|
'topic:langchain topic:autogpt',
|
||||||
|
];
|
||||||
|
|
||||||
|
export const TRENDING_MIN_STARS = 10;
|
||||||
|
export const TRENDING_PUSHED_DAYS_AGO = 7;
|
||||||
|
export const TRENDING_MAX_RESULTS = 15;
|
||||||
|
|
||||||
|
// --- New AI Repos ---
|
||||||
|
export const NEW_REPO_TOPICS = [
|
||||||
|
'ai-agent',
|
||||||
|
'llm-agent',
|
||||||
|
'autonomous-agent',
|
||||||
|
'ai-agents',
|
||||||
|
'agent-framework',
|
||||||
|
];
|
||||||
|
|
||||||
|
export const NEW_REPOS_MIN_STARS = 100;
|
||||||
|
export const NEW_REPOS_CREATED_DAYS_AGO = 7;
|
||||||
|
export const NEW_REPOS_MAX_RESULTS = 15;
|
||||||
|
|
||||||
|
// --- Claude Code Releases ---
|
||||||
|
export const GITHUB_API = 'https://api.github.com';
|
||||||
|
export const NPM_REGISTRY = 'https://registry.npmjs.org';
|
||||||
|
export const CLAUDE_CODE_PACKAGE = '@anthropic-ai/claude-code';
|
||||||
|
export const LAST_VERSION_FILE = new URL(
|
||||||
|
'../../.last-version',
|
||||||
|
import.meta.url
|
||||||
|
).pathname;
|
||||||
65
src/feeds/claude-code-releases.ts
Normal file
65
src/feeds/claude-code-releases.ts
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
import { existsSync, readFileSync, writeFileSync } from 'fs';
|
||||||
|
import {
|
||||||
|
CLAUDE_CODE_PACKAGE,
|
||||||
|
NPM_REGISTRY,
|
||||||
|
LAST_VERSION_FILE,
|
||||||
|
} from '../config';
|
||||||
|
import { log } from '../utils';
|
||||||
|
|
||||||
|
export async function run(): Promise<string> {
|
||||||
|
log('[claude-releases] Checking npm for claude-code updates...');
|
||||||
|
|
||||||
|
const url = `${NPM_REGISTRY}/${CLAUDE_CODE_PACKAGE}`;
|
||||||
|
const res = await fetch(url, {
|
||||||
|
headers: { Accept: 'application/json' },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
log(` ⚠ npm registry ${res.status}`);
|
||||||
|
return '## Claude Code Releases\n\n_Failed to check registry._\n';
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = (await res.json()) as {
|
||||||
|
'dist-tags': { latest: string };
|
||||||
|
time: Record<string, string>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const latest = data['dist-tags'].latest;
|
||||||
|
const publishedAt = data.time?.[latest] ?? 'unknown';
|
||||||
|
|
||||||
|
let lastSeen = '';
|
||||||
|
if (existsSync(LAST_VERSION_FILE)) {
|
||||||
|
lastSeen = readFileSync(LAST_VERSION_FILE, 'utf-8').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (latest === lastSeen) {
|
||||||
|
log(`[claude-releases] No new version (current: ${latest})`);
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
writeFileSync(LAST_VERSION_FILE, latest, 'utf-8');
|
||||||
|
log(
|
||||||
|
`[claude-releases] New version: ${latest} ` +
|
||||||
|
`(was: ${lastSeen || 'none'})`
|
||||||
|
);
|
||||||
|
|
||||||
|
const changelogUrl =
|
||||||
|
'https://github.com/anthropics/claude-code/releases';
|
||||||
|
const npmUrl =
|
||||||
|
`https://www.npmjs.com/package/${CLAUDE_CODE_PACKAGE}`;
|
||||||
|
|
||||||
|
const lines = [
|
||||||
|
'## 🚀 Claude Code — New Release\n',
|
||||||
|
`**Version:** \`${latest}\``,
|
||||||
|
`**Published:** ${publishedAt}\n`,
|
||||||
|
`📦 [npm](${npmUrl}) | 📝 [Releases](${changelogUrl})`,
|
||||||
|
];
|
||||||
|
|
||||||
|
if (lastSeen) lines.push(`\n_Previous: \`${lastSeen}\`_`);
|
||||||
|
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (import.meta.main) {
|
||||||
|
console.log(await run());
|
||||||
|
}
|
||||||
40
src/feeds/github-trending.ts
Normal file
40
src/feeds/github-trending.ts
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
import {
|
||||||
|
TRENDING_QUERIES,
|
||||||
|
TRENDING_MIN_STARS,
|
||||||
|
TRENDING_PUSHED_DAYS_AGO,
|
||||||
|
TRENDING_MAX_RESULTS,
|
||||||
|
} from '../config';
|
||||||
|
import {
|
||||||
|
daysAgo,
|
||||||
|
log,
|
||||||
|
ghSearch,
|
||||||
|
formatRepos,
|
||||||
|
dedupeRepos,
|
||||||
|
type GHRepo,
|
||||||
|
} from '../utils';
|
||||||
|
|
||||||
|
export async function run(): Promise<string> {
|
||||||
|
log('[trending] Searching GitHub for trending AI/agent repos...');
|
||||||
|
|
||||||
|
const since = daysAgo(TRENDING_PUSHED_DAYS_AGO);
|
||||||
|
const baseFilter = `stars:>${TRENDING_MIN_STARS} pushed:>${since}`;
|
||||||
|
|
||||||
|
const all: GHRepo[] = [];
|
||||||
|
|
||||||
|
for (const q of TRENDING_QUERIES) {
|
||||||
|
const query = `${q} ${baseFilter}`;
|
||||||
|
const repos = await ghSearch(query);
|
||||||
|
all.push(...repos);
|
||||||
|
}
|
||||||
|
|
||||||
|
const unique = dedupeRepos(all)
|
||||||
|
.sort((a, b) => b.stargazers_count - a.stargazers_count)
|
||||||
|
.slice(0, TRENDING_MAX_RESULTS);
|
||||||
|
|
||||||
|
log(`[trending] Found ${unique.length} repos`);
|
||||||
|
return formatRepos(unique, '🔥 Trending AI/Agent Repos');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (import.meta.main) {
|
||||||
|
console.log(await run());
|
||||||
|
}
|
||||||
40
src/feeds/new-ai-repos.ts
Normal file
40
src/feeds/new-ai-repos.ts
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
import {
|
||||||
|
NEW_REPO_TOPICS,
|
||||||
|
NEW_REPOS_MIN_STARS,
|
||||||
|
NEW_REPOS_CREATED_DAYS_AGO,
|
||||||
|
NEW_REPOS_MAX_RESULTS,
|
||||||
|
} from '../config';
|
||||||
|
import {
|
||||||
|
daysAgo,
|
||||||
|
log,
|
||||||
|
ghSearch,
|
||||||
|
formatRepos,
|
||||||
|
dedupeRepos,
|
||||||
|
type GHRepo,
|
||||||
|
} from '../utils';
|
||||||
|
|
||||||
|
export async function run(): Promise<string> {
|
||||||
|
log('[new-repos] Searching for new AI repos (last 7 days)...');
|
||||||
|
|
||||||
|
const since = daysAgo(NEW_REPOS_CREATED_DAYS_AGO);
|
||||||
|
const baseFilter = `created:>${since} stars:>${NEW_REPOS_MIN_STARS}`;
|
||||||
|
|
||||||
|
const all: GHRepo[] = [];
|
||||||
|
|
||||||
|
for (const topic of NEW_REPO_TOPICS) {
|
||||||
|
const query = `topic:${topic} ${baseFilter}`;
|
||||||
|
const repos = await ghSearch(query);
|
||||||
|
all.push(...repos);
|
||||||
|
}
|
||||||
|
|
||||||
|
const unique = dedupeRepos(all)
|
||||||
|
.sort((a, b) => b.stargazers_count - a.stargazers_count)
|
||||||
|
.slice(0, NEW_REPOS_MAX_RESULTS);
|
||||||
|
|
||||||
|
log(`[new-repos] Found ${unique.length} new repos`);
|
||||||
|
return formatRepos(unique, '🆕 New AI/Agent Repos (Past 7 Days)');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (import.meta.main) {
|
||||||
|
console.log(await run());
|
||||||
|
}
|
||||||
152
src/feeds/reddit-digest.ts
Normal file
152
src/feeds/reddit-digest.ts
Normal file
@ -0,0 +1,152 @@
|
|||||||
|
import {
|
||||||
|
REDDIT_SUBREDDITS,
|
||||||
|
REDDIT_LISTINGS,
|
||||||
|
REDDIT_MIN_SCORE,
|
||||||
|
REDDIT_MAX_AGE_HOURS,
|
||||||
|
REDDIT_MAX_POSTS_PER_SUB,
|
||||||
|
REDDIT_SNIPPET_LENGTH,
|
||||||
|
REDDIT_REQUEST_DELAY_MS,
|
||||||
|
REDDIT_POSTS_PER_PAGE,
|
||||||
|
REDDIT_USER_AGENT,
|
||||||
|
} from '../config';
|
||||||
|
import { log, sleep } from '../utils';
|
||||||
|
|
||||||
|
// --- Types ---
|
||||||
|
|
||||||
|
interface RedditPost {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
selftext: string;
|
||||||
|
score: number;
|
||||||
|
num_comments: number;
|
||||||
|
created_utc: number;
|
||||||
|
permalink: string;
|
||||||
|
subreddit: string;
|
||||||
|
is_self: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RedditListing {
|
||||||
|
data: {
|
||||||
|
children: Array<{ kind: string; data: RedditPost }>;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Scraper ---
|
||||||
|
|
||||||
|
async function fetchListing(
|
||||||
|
subreddit: string,
|
||||||
|
listing: string
|
||||||
|
): Promise<RedditPost[]> {
|
||||||
|
const url =
|
||||||
|
`https://www.reddit.com/r/${subreddit}/${listing}.json` +
|
||||||
|
`?limit=${REDDIT_POSTS_PER_PAGE}&raw_json=1`;
|
||||||
|
|
||||||
|
const res = await fetch(url, {
|
||||||
|
headers: {
|
||||||
|
'User-Agent': REDDIT_USER_AGENT,
|
||||||
|
Accept: 'application/json',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
log(` [warn] r/${subreddit}/${listing}: ${res.status}`);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const json = (await res.json()) as RedditListing;
|
||||||
|
return json.data.children
|
||||||
|
.filter((c) => c.kind === 't3')
|
||||||
|
.map((c) => c.data);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function scrapeSubreddit(
|
||||||
|
subreddit: string
|
||||||
|
): Promise<RedditPost[]> {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const posts: RedditPost[] = [];
|
||||||
|
|
||||||
|
for (const listing of REDDIT_LISTINGS) {
|
||||||
|
const fetched = await fetchListing(subreddit, listing);
|
||||||
|
for (const post of fetched) {
|
||||||
|
if (!seen.has(post.id)) {
|
||||||
|
seen.add(post.id);
|
||||||
|
posts.push(post);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await sleep(REDDIT_REQUEST_DELAY_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
return posts;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Filter ---
|
||||||
|
|
||||||
|
function filterPosts(posts: RedditPost[]): RedditPost[] {
|
||||||
|
const cutoff = Date.now() / 1000 - REDDIT_MAX_AGE_HOURS * 3600;
|
||||||
|
|
||||||
|
return posts
|
||||||
|
.filter((p) => p.score >= REDDIT_MIN_SCORE && p.created_utc >= cutoff)
|
||||||
|
.sort((a, b) => b.score - a.score)
|
||||||
|
.slice(0, REDDIT_MAX_POSTS_PER_SUB);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Format ---
|
||||||
|
|
||||||
|
function snippet(text: string): string {
|
||||||
|
if (!text) return '';
|
||||||
|
const clean = text.replace(/\n+/g, ' ').trim();
|
||||||
|
if (clean.length <= REDDIT_SNIPPET_LENGTH) return clean;
|
||||||
|
return clean.slice(0, REDDIT_SNIPPET_LENGTH).trimEnd() + '…';
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatPost(post: RedditPost): string {
|
||||||
|
const link = `https://reddit.com${post.permalink}`;
|
||||||
|
const meta = `⬆ ${post.score} | 💬 ${post.num_comments}`;
|
||||||
|
const selfSnippet = post.is_self ? snippet(post.selftext) : '';
|
||||||
|
|
||||||
|
let out = `**[${post.title}](${link})**\n${meta}`;
|
||||||
|
if (selfSnippet) out += `\n> ${selfSnippet}`;
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDigest(grouped: Map<string, RedditPost[]>): string {
|
||||||
|
if (grouped.size === 0) {
|
||||||
|
return '*No posts matched the filters in the last 24h.*';
|
||||||
|
}
|
||||||
|
|
||||||
|
const sections: string[] = [];
|
||||||
|
for (const [subreddit, posts] of grouped) {
|
||||||
|
const header = `## r/${subreddit}`;
|
||||||
|
const body = posts.map(formatPost).join('\n\n');
|
||||||
|
sections.push(`${header}\n${body}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date().toISOString().slice(0, 16).replace('T', ' ');
|
||||||
|
return `# 🤖 AI Reddit Digest — ${now} UTC\n\n` +
|
||||||
|
sections.join('\n\n---\n\n') + '\n';
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Entry ---
|
||||||
|
|
||||||
|
export async function run(): Promise<string> {
|
||||||
|
log('[reddit] Fetching AI subreddit digest...');
|
||||||
|
|
||||||
|
const grouped = new Map<string, RedditPost[]>();
|
||||||
|
|
||||||
|
for (const subreddit of REDDIT_SUBREDDITS) {
|
||||||
|
log(` r/${subreddit}...`);
|
||||||
|
const raw = await scrapeSubreddit(subreddit);
|
||||||
|
const filtered = filterPosts(raw);
|
||||||
|
|
||||||
|
if (filtered.length > 0) {
|
||||||
|
grouped.set(subreddit, filtered);
|
||||||
|
}
|
||||||
|
log(` ${raw.length} fetched, ${filtered.length} kept`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return formatDigest(grouped);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (import.meta.main) {
|
||||||
|
console.log(await run());
|
||||||
|
}
|
||||||
51
src/index.ts
Normal file
51
src/index.ts
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
import { run as reddit } from './feeds/reddit-digest';
|
||||||
|
import { run as trending } from './feeds/github-trending';
|
||||||
|
import { run as newRepos } from './feeds/new-ai-repos';
|
||||||
|
import { run as claudeReleases } from './feeds/claude-code-releases';
|
||||||
|
import { log } from './utils';
|
||||||
|
|
||||||
|
const COMMANDS: Record<string, () => Promise<string>> = {
|
||||||
|
reddit,
|
||||||
|
trending,
|
||||||
|
'new-repos': newRepos,
|
||||||
|
'claude-releases': claudeReleases,
|
||||||
|
};
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const cmd = process.argv[2];
|
||||||
|
|
||||||
|
if (!cmd || cmd === '--help' || cmd === '-h') {
|
||||||
|
console.error(
|
||||||
|
'Usage: bun run feed ' +
|
||||||
|
'<reddit|trending|new-repos|claude-releases|all>'
|
||||||
|
);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cmd === 'all') {
|
||||||
|
for (const [name, fn] of Object.entries(COMMANDS)) {
|
||||||
|
log(`\n--- ${name} ---`);
|
||||||
|
const output = await fn();
|
||||||
|
if (output) console.log(output);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fn = COMMANDS[cmd];
|
||||||
|
if (!fn) {
|
||||||
|
console.error(`Unknown command: ${cmd}`);
|
||||||
|
console.error(
|
||||||
|
`Available: ${Object.keys(COMMANDS).join(', ')}, all`
|
||||||
|
);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const output = await fn();
|
||||||
|
if (output) console.log(output);
|
||||||
|
else log('(no output)');
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((e) => {
|
||||||
|
console.error(e);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
70
src/utils.ts
Normal file
70
src/utils.ts
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
export function daysAgo(n: number): string {
|
||||||
|
const d = new Date();
|
||||||
|
d.setDate(d.getDate() - n);
|
||||||
|
return d.toISOString().split('T')[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function log(...args: unknown[]) {
|
||||||
|
console.error(...args);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sleep(ms: number): Promise<void> {
|
||||||
|
return new Promise((r) => setTimeout(r, ms));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function ghSearch(query: string): Promise<GHRepo[]> {
|
||||||
|
const url =
|
||||||
|
`https://api.github.com/search/repositories` +
|
||||||
|
`?q=${encodeURIComponent(query)}&sort=stars&order=desc&per_page=30`;
|
||||||
|
log(` fetching: ${query}`);
|
||||||
|
|
||||||
|
const res = await fetch(url, {
|
||||||
|
headers: { Accept: 'application/vnd.github.v3+json' },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
log(` ⚠ GitHub API ${res.status}: ${await res.text()}`);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = (await res.json()) as { items: GHRepo[] };
|
||||||
|
return data.items ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GHRepo {
|
||||||
|
full_name: string;
|
||||||
|
description: string | null;
|
||||||
|
stargazers_count: number;
|
||||||
|
language: string | null;
|
||||||
|
html_url: string;
|
||||||
|
created_at: string;
|
||||||
|
pushed_at: string;
|
||||||
|
topics?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatRepos(repos: GHRepo[], title: string): string {
|
||||||
|
if (!repos.length) return `## ${title}\n\n_No results found._\n`;
|
||||||
|
|
||||||
|
const lines = [`## ${title}\n`];
|
||||||
|
for (const r of repos) {
|
||||||
|
const desc = r.description
|
||||||
|
? r.description.slice(0, 120)
|
||||||
|
: '_No description_';
|
||||||
|
const lang = r.language ?? '?';
|
||||||
|
lines.push(
|
||||||
|
`**[${r.full_name}](${r.html_url})** ` +
|
||||||
|
`⭐ ${r.stargazers_count.toLocaleString()} | \`${lang}\``
|
||||||
|
);
|
||||||
|
lines.push(`> ${desc}\n`);
|
||||||
|
}
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function dedupeRepos(repos: GHRepo[]): GHRepo[] {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
return repos.filter((r) => {
|
||||||
|
if (seen.has(r.full_name)) return false;
|
||||||
|
seen.add(r.full_name);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user