← back to Goodquestion Ai
scripts/boris-watcher.cjs
358 lines
#!/usr/bin/env node
/**
* Boris Watcher — monitors @bcherny for new tweets/posts
* When Boris tweets, generates a "big news" amplification post giving him credit.
*
* Usage:
* node boris-watcher.cjs # Check and amplify
* node boris-watcher.cjs --dry-run # Preview without posting
* node boris-watcher.cjs --force # Post even if already covered
*
* Cron: every 30min, 7am-10pm PT
*/
require('dotenv').config({ path: require('path').join(__dirname, '..', '.env') });
const { BskyAgent, RichText } = require('@atproto/api');
const { TwitterApi } = require('twitter-api-v2');
const fs = require('fs');
const path = require('path');
const STATE_FILE = '/tmp/boris-watcher-state.json';
const BSKY_HANDLE = 'agentabrams.bsky.social';
const BSKY_PASSWORD = process.env.BSKY_PASSWORD;
const BORIS = {
name: 'Boris Cherny',
title: 'Head of Claude Code at Anthropic',
x: 'bcherny',
bsky: 'bcherny.bsky.social',
};
// Keywords that make a Boris tweet newsworthy (not every tweet needs coverage)
const NEWS_KEYWORDS = [
'claude code', 'claude', 'anthropic', 'agent', 'release', 'launch',
'ship', 'update', 'feature', 'model', 'opus', 'sonnet', 'haiku',
'mcp', 'tool use', 'coding', 'developer', 'sdk', 'api',
'benchmark', 'context', 'autonomous', 'agentic', 'vibe cod',
];
function parseArgs() {
const args = process.argv.slice(2);
return {
dryRun: args.includes('--dry-run'),
force: args.includes('--force'),
};
}
function loadState() {
try { return JSON.parse(fs.readFileSync(STATE_FILE, 'utf-8')); }
catch { return { coveredPosts: {}, lastCheck: null }; }
}
function saveState(state) {
state.lastCheck = new Date().toISOString();
fs.writeFileSync(STATE_FILE, JSON.stringify(state, null, 2));
}
/**
* Generate a "big news" post based on Boris's tweet content
*/
function generateAmplificationPost(tweetText, platform) {
// Analyze the tweet to understand the topic
const lowerText = tweetText.toLowerCase();
let topic = 'Claude Code';
let angle = '';
if (lowerText.includes('release') || lowerText.includes('launch') || lowerText.includes('ship') || lowerText.includes('just dropped')) {
angle = 'just dropped news';
topic = 'a new release';
} else if (lowerText.includes('model') || lowerText.includes('opus') || lowerText.includes('sonnet') || lowerText.includes('haiku')) {
angle = 'model update';
topic = 'model improvements';
} else if (lowerText.includes('agent') || lowerText.includes('autonomous') || lowerText.includes('agentic')) {
angle = 'agentic capabilities';
topic = 'agent workflows';
} else if (lowerText.includes('mcp') || lowerText.includes('tool')) {
angle = 'tooling update';
topic = 'developer tooling';
} else if (lowerText.includes('benchmark') || lowerText.includes('performance')) {
angle = 'performance news';
topic = 'performance gains';
} else if (lowerText.includes('context') || lowerText.includes('window')) {
angle = 'context improvements';
topic = 'context handling';
} else if (lowerText.includes('feature') || lowerText.includes('update')) {
angle = 'feature update';
topic = 'new capabilities';
} else {
angle = 'development insight';
topic = 'building with Claude Code';
}
// Extract the core message (first sentence or up to 120 chars)
let coreSummary = tweetText.split(/[.!?\n]/)[0].trim();
if (coreSummary.length > 120) coreSummary = coreSummary.substring(0, 117) + '...';
// Build the amplification post
const templates = [
`Big news from @${platform === 'bsky' ? BORIS.bsky : BORIS.x} (${BORIS.title}):\n\n"${coreSummary}"\n\nThis is huge for everyone building with Claude Code. The ${topic} space keeps accelerating.\n\n#ClaudeCode #Anthropic #BuildInPublic`,
`${BORIS.name}, ${BORIS.title}, just shared something worth paying attention to:\n\n"${coreSummary}"\n\nIf you're building with Claude Code, this matters. ${angle === 'just dropped news' ? 'Go check it out.' : 'The pace of improvement is incredible.'}\n\n#ClaudeCode #AI #BuildInPublic`,
`Heads up builders — @${platform === 'bsky' ? BORIS.bsky : BORIS.x} just posted about ${topic}:\n\n"${coreSummary}"\n\nBuilding with Claude Code daily and these updates compound fast. Credit to the team at Anthropic.\n\n#ClaudeCode #BuildInPublic`,
];
// Pick a template (rotate based on hour)
const idx = new Date().getHours() % templates.length;
return templates[idx];
}
/**
* Check Bluesky for Boris's recent posts
*/
async function checkBlueskyBoris(state, opts) {
const agent = new BskyAgent({ service: 'https://bsky.social' });
await agent.login({ identifier: BSKY_HANDLE, password: BSKY_PASSWORD });
const profile = await agent.getProfile({ actor: BORIS.bsky });
const feed = await agent.getAuthorFeed({ actor: profile.data.did, limit: 10 });
const newPosts = [];
for (const item of feed.data.feed) {
if (item.post.author.did !== profile.data.did) continue; // Skip reposts
const postAge = Date.now() - new Date(item.post.record.createdAt).getTime();
if (postAge > 12 * 60 * 60 * 1000) continue; // Only last 12h
const postKey = `bsky:${item.post.uri}`;
if (state.coveredPosts[postKey] && !opts.force) continue;
const text = item.post.record.text || '';
const isNewsworthy = NEWS_KEYWORDS.some(k => text.toLowerCase().includes(k));
if (isNewsworthy) {
newPosts.push({
platform: 'bsky',
key: postKey,
text,
uri: item.post.uri,
cid: item.post.cid,
createdAt: item.post.record.createdAt,
metrics: {
likes: item.post.likeCount || 0,
reposts: item.post.repostCount || 0,
replies: item.post.replyCount || 0,
},
});
}
}
// Like all his recent posts regardless
for (const item of feed.data.feed) {
if (item.post.author.did !== profile.data.did) continue;
if (!item.post.viewer?.like && !opts.dryRun) {
try { await agent.like(item.post.uri, item.post.cid); } catch {}
}
}
return { agent, newPosts };
}
/**
* Check X/Twitter for Boris's recent tweets
*/
async function checkTwitterBoris(state, opts) {
const client = new TwitterApi({
appKey: process.env.TWITTER_API_KEY,
appSecret: process.env.TWITTER_API_SECRET,
accessToken: process.env.TWITTER_ACCESS_TOKEN,
accessSecret: process.env.TWITTER_ACCESS_TOKEN_SECRET,
});
const newPosts = [];
try {
const user = await client.v2.userByUsername(BORIS.x);
if (!user.data) return { client, newPosts };
const timeline = await client.v2.userTimeline(user.data.id, {
max_results: 10,
'tweet.fields': 'created_at,public_metrics',
exclude: ['retweets'],
});
const me = await client.v2.me();
for (const tweet of (timeline.data?.data || [])) {
const tweetAge = Date.now() - new Date(tweet.created_at).getTime();
if (tweetAge > 12 * 60 * 60 * 1000) continue;
const postKey = `x:${tweet.id}`;
if (state.coveredPosts[postKey] && !opts.force) continue;
const isNewsworthy = NEWS_KEYWORDS.some(k => tweet.text.toLowerCase().includes(k));
if (isNewsworthy) {
newPosts.push({
platform: 'x',
key: postKey,
text: tweet.text,
tweetId: tweet.id,
createdAt: tweet.created_at,
metrics: tweet.public_metrics || {},
});
}
// Like all his recent tweets
if (!opts.dryRun) {
try { await client.v2.like(me.data.id, tweet.id); } catch {}
}
}
} catch (e) {
if (e.message && e.message.includes('429')) {
console.warn('[X] Rate limited — will retry next run');
} else {
console.error('[X] Error:', e.message);
}
}
return { client, newPosts };
}
/**
* Post amplification to both platforms
*/
async function amplify(post, state, opts) {
console.log(`\n=== AMPLIFYING: ${post.platform} post ===`);
console.log(`Boris said: "${post.text.substring(0, 100)}..."`);
console.log(`Metrics: ${JSON.stringify(post.metrics)}`);
// Generate amplification posts for each platform
const bskyPost = generateAmplificationPost(post.text, 'bsky');
const xPost = generateAmplificationPost(post.text, 'x');
if (opts.dryRun) {
console.log('\n[DRY RUN] Would post to Bluesky:', bskyPost);
console.log('\n[DRY RUN] Would post to X:', xPost);
return;
}
// Post to Bluesky
try {
const agent = new BskyAgent({ service: 'https://bsky.social' });
await agent.login({ identifier: BSKY_HANDLE, password: BSKY_PASSWORD });
let bskyText = bskyPost;
if (bskyText.length > 300) bskyText = bskyText.substring(0, 297) + '...';
const rt = new RichText({ text: bskyText });
await rt.detectFacets(agent);
const result = await agent.post({
text: rt.text,
facets: rt.facets,
createdAt: new Date().toISOString(),
});
const postId = result.uri.split('/').pop();
console.log(`[Bluesky] Amplification posted: https://bsky.app/profile/${BSKY_HANDLE}/post/${postId}`);
} catch (e) {
console.error('[Bluesky] Amplification failed:', e.message);
}
// Post to X/Twitter
try {
const client = new TwitterApi({
appKey: process.env.TWITTER_API_KEY,
appSecret: process.env.TWITTER_API_SECRET,
accessToken: process.env.TWITTER_ACCESS_TOKEN,
accessSecret: process.env.TWITTER_ACCESS_TOKEN_SECRET,
});
let tweetText = xPost;
if (tweetText.length > 280) tweetText = tweetText.substring(0, 277) + '...';
const result = await client.v2.tweet(tweetText);
console.log(`[X] Amplification posted: https://x.com/agentabrams/status/${result.data.id}`);
} catch (e) {
if (e.message && e.message.includes('429')) {
// Save for retry
fs.writeFileSync('/tmp/x-pending-boris-amplify.json', JSON.stringify({
text: xPost,
borisPost: post.key,
created: new Date().toISOString(),
}, null, 2));
console.warn('[X] Rate limited — saved for retry at /tmp/x-pending-boris-amplify.json');
} else {
console.error('[X] Amplification failed:', e.message);
}
}
// Mark as covered
state.coveredPosts[post.key] = {
coveredAt: new Date().toISOString(),
originalText: post.text.substring(0, 200),
};
}
async function main() {
const opts = parseArgs();
const state = loadState();
console.log(`\n═══ Boris Watcher ═══`);
console.log(`Time: ${new Date().toLocaleString('en-US', { timeZone: 'America/Los_Angeles' })} PT`);
console.log(`Last check: ${state.lastCheck || 'never'}`);
if (opts.dryRun) console.log('[DRY RUN MODE]');
let allNewPosts = [];
// Check Bluesky
console.log('\n[Bluesky] Checking @bcherny.bsky.social...');
try {
const { newPosts } = await checkBlueskyBoris(state, opts);
console.log(`[Bluesky] ${newPosts.length} newsworthy posts found`);
allNewPosts = allNewPosts.concat(newPosts);
} catch (e) {
console.error('[Bluesky] Error:', e.message);
}
// Check X/Twitter
console.log('\n[X] Checking @bcherny...');
try {
const { newPosts } = await checkTwitterBoris(state, opts);
console.log(`[X] ${newPosts.length} newsworthy tweets found`);
allNewPosts = allNewPosts.concat(newPosts);
} catch (e) {
console.error('[X] Error:', e.message);
}
// Amplify the most recent newsworthy post (max 1 per run to avoid spam)
if (allNewPosts.length > 0) {
// Sort by recency, pick most recent
allNewPosts.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
await amplify(allNewPosts[0], state, opts);
// If there are more, log them
if (allNewPosts.length > 1) {
console.log(`\n${allNewPosts.length - 1} more newsworthy posts (will cover next run):`);
for (const p of allNewPosts.slice(1)) {
console.log(` - ${p.platform}: "${p.text.substring(0, 80)}..."`);
}
}
} else {
console.log('\nNo new newsworthy Boris posts to amplify.');
}
// Prune old state entries (>7 days)
const cutoff = Date.now() - 7 * 24 * 60 * 60 * 1000;
for (const [key, val] of Object.entries(state.coveredPosts)) {
if (val.coveredAt && new Date(val.coveredAt).getTime() < cutoff) {
delete state.coveredPosts[key];
}
}
saveState(state);
console.log('\n═══ Boris Watcher complete ═══');
}
main().catch(e => { console.error('Fatal:', e.message); process.exit(1); });