Initial commit: config-driven Twitter bot with multi-prompt support
- Kernel.sh stealth browser for X login and posting - Claude Sonnet 4.6 for tweet generation - prompts.json for configurable prompts with frequency/scheduling - Per-prompt history tracking to avoid repetition - Scheduler with random time window support Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
276
bot.js
Normal file
276
bot.js
Normal file
@@ -0,0 +1,276 @@
|
||||
import "dotenv/config";
|
||||
import Kernel from "@onkernel/sdk";
|
||||
import { readFileSync, writeFileSync, existsSync } from "fs";
|
||||
import { fileURLToPath } from "url";
|
||||
import { dirname, join } from "path";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const PROMPTS_FILE = join(__dirname, "prompts.json");
|
||||
|
||||
function loadPrompts() {
|
||||
return JSON.parse(readFileSync(PROMPTS_FILE, "utf-8"));
|
||||
}
|
||||
|
||||
function historyPath(name) {
|
||||
return join(__dirname, `history-${name}.json`);
|
||||
}
|
||||
|
||||
function loadHistory(name) {
|
||||
try {
|
||||
return JSON.parse(readFileSync(historyPath(name), "utf-8"));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function saveHistory(name, history) {
|
||||
writeFileSync(historyPath(name), JSON.stringify(history, null, 2));
|
||||
}
|
||||
|
||||
function shouldRun(promptConfig) {
|
||||
const history = loadHistory(promptConfig.name);
|
||||
const now = new Date();
|
||||
|
||||
if (promptConfig.frequency === "daily") {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (promptConfig.frequency === "2x_week") {
|
||||
// Max 2 per week
|
||||
const dayOfWeek = now.getDay();
|
||||
const mondayOffset = dayOfWeek === 0 ? 6 : dayOfWeek - 1;
|
||||
const monday = new Date(now);
|
||||
monday.setDate(monday.getDate() - mondayOffset);
|
||||
monday.setHours(0, 0, 0, 0);
|
||||
|
||||
const thisWeekPosts = history.filter((h) => new Date(h.date) >= monday);
|
||||
if (thisWeekPosts.length >= 2) return false;
|
||||
|
||||
// Min 2 days between posts
|
||||
if (history.length > 0) {
|
||||
const lastPost = new Date(history[history.length - 1].date);
|
||||
const daysSinceLast = (now - lastPost) / (1000 * 60 * 60 * 24);
|
||||
if (daysSinceLast < 2) return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function buildPrompt(promptConfig, history) {
|
||||
let prompt = promptConfig.prompt;
|
||||
prompt += "\n\nGenerate 1 tweet. Keep it under 280 characters.\n\nDo not explain the tweet. Do not add options. Just output the tweet.";
|
||||
|
||||
if (history.length > 0) {
|
||||
const recent = history.slice(-20).map((h) => h.tweet).join("\n---\n");
|
||||
prompt += "\n\nHere are previous tweets. Do NOT repeat or closely resemble any of these:\n\n" + recent;
|
||||
}
|
||||
|
||||
return prompt;
|
||||
}
|
||||
|
||||
async function generateTweet(promptConfig, history) {
|
||||
const res = await fetch("https://api.anthropic.com/v1/messages", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"x-api-key": process.env.ANTHROPIC,
|
||||
"anthropic-version": "2023-06-01",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: "claude-sonnet-4-6",
|
||||
max_tokens: 280,
|
||||
messages: [{ role: "user", content: buildPrompt(promptConfig, history) }],
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`Anthropic API error: ${res.status} ${await res.text()}`);
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
return data.content[0].text.trim();
|
||||
}
|
||||
|
||||
async function exec(kernel, sessionId, code, timeout = 30) {
|
||||
const result = await kernel.browsers.playwright.execute(sessionId, {
|
||||
code,
|
||||
timeout_sec: timeout,
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
async function readPage(kernel, sessionId) {
|
||||
const result = await exec(kernel, sessionId, `
|
||||
await page.waitForTimeout(2000);
|
||||
const inputs = await page.locator('input').all();
|
||||
const info = [];
|
||||
for (const inp of inputs) {
|
||||
const type = await inp.getAttribute('type');
|
||||
const name = await inp.getAttribute('name');
|
||||
info.push({ type, name });
|
||||
}
|
||||
const texts = await page.locator('h1, h2, span').allTextContents();
|
||||
return { url: page.url(), inputs: info, texts: texts.filter(t => t.trim()).slice(0, 20) };
|
||||
`);
|
||||
if (!result.success) throw new Error("readPage failed: " + result.error);
|
||||
return result.result;
|
||||
}
|
||||
|
||||
async function login(kernel, sessionId) {
|
||||
const xAcct = JSON.stringify(process.env.X_ACCT);
|
||||
const xPw = JSON.stringify(process.env.X_PW);
|
||||
const xEmail = JSON.stringify(process.env.X_EMAIL);
|
||||
const xPhone = JSON.stringify(process.env.X_PHONE);
|
||||
|
||||
await exec(kernel, sessionId, `
|
||||
await page.goto('https://x.com/login', { waitUntil: 'networkidle' });
|
||||
await page.waitForTimeout(3000);
|
||||
`);
|
||||
|
||||
const r1 = await exec(kernel, sessionId, `
|
||||
const input = page.locator('input[autocomplete="username"]');
|
||||
await input.waitFor({ state: 'visible', timeout: 10000 });
|
||||
await input.click();
|
||||
await input.fill(${xAcct});
|
||||
await page.waitForTimeout(500);
|
||||
const nextBtn = page.locator('[role="button"]').filter({ hasText: 'Next' });
|
||||
await nextBtn.click();
|
||||
await page.waitForTimeout(5000);
|
||||
`, 30);
|
||||
console.log("Username:", r1.success ? "ok" : r1.error);
|
||||
if (!r1.success) throw new Error("Username failed: " + r1.error);
|
||||
|
||||
let page = await readPage(kernel, sessionId);
|
||||
console.log("After username:", JSON.stringify(page, null, 2));
|
||||
|
||||
const hasPassword = page.inputs.some((i) => i.type === "password");
|
||||
const pageText = page.texts.join(" ").toLowerCase();
|
||||
|
||||
if (!hasPassword && (pageText.includes("unusual login") || pageText.includes("verify") || pageText.includes("confirm"))) {
|
||||
let verifyValue;
|
||||
if (pageText.includes("phone number or email")) {
|
||||
verifyValue = xPhone;
|
||||
} else if (pageText.includes("email")) {
|
||||
verifyValue = xEmail;
|
||||
} else {
|
||||
verifyValue = xPhone;
|
||||
}
|
||||
console.log("Verification required");
|
||||
|
||||
const rv = await exec(kernel, sessionId, `
|
||||
const input = page.locator('input[type="text"]');
|
||||
await input.waitFor({ state: 'visible', timeout: 10000 });
|
||||
await input.click();
|
||||
await input.fill(${verifyValue});
|
||||
await page.waitForTimeout(500);
|
||||
const nextBtn = page.locator('[role="button"]').filter({ hasText: 'Next' });
|
||||
await nextBtn.click();
|
||||
await page.waitForTimeout(5000);
|
||||
`, 30);
|
||||
if (!rv.success) throw new Error("Verification failed: " + rv.error);
|
||||
|
||||
page = await readPage(kernel, sessionId);
|
||||
}
|
||||
|
||||
const r2 = await exec(kernel, sessionId, `
|
||||
const input = page.locator('input[type="password"]');
|
||||
await input.waitFor({ state: 'visible', timeout: 10000 });
|
||||
await input.click();
|
||||
await input.fill(${xPw});
|
||||
await page.waitForTimeout(500);
|
||||
const loginBtn = page.getByTestId('LoginForm_Login_Button');
|
||||
await loginBtn.click();
|
||||
await page.waitForTimeout(5000);
|
||||
`, 30);
|
||||
if (!r2.success) throw new Error("Password failed: " + r2.error);
|
||||
|
||||
page = await readPage(kernel, sessionId);
|
||||
console.log("Logged in:", page.url);
|
||||
return page.url;
|
||||
}
|
||||
|
||||
async function postTweet(kernel, sessionId, tweetText) {
|
||||
const composeResult = await exec(kernel, sessionId, `
|
||||
const composeBtn = page.getByTestId('SideNav_NewTweet_Button');
|
||||
await composeBtn.waitFor({ state: 'visible', timeout: 10000 });
|
||||
await composeBtn.click();
|
||||
await page.waitForTimeout(2000);
|
||||
const editor = page.getByRole('textbox');
|
||||
await editor.waitFor({ state: 'visible', timeout: 10000 });
|
||||
return 'ready';
|
||||
`, 30);
|
||||
if (!composeResult.success) throw new Error("Compose failed: " + composeResult.error);
|
||||
|
||||
const tweetStr = JSON.stringify(tweetText);
|
||||
const post = await exec(kernel, sessionId, `
|
||||
const editor = page.getByRole('textbox');
|
||||
await editor.click();
|
||||
await page.keyboard.type(${tweetStr}, { delay: 30 });
|
||||
await page.waitForTimeout(1000);
|
||||
const postButton = page.getByTestId('tweetButton');
|
||||
await postButton.click();
|
||||
await page.waitForTimeout(5000);
|
||||
return { url: page.url() };
|
||||
`, 120);
|
||||
|
||||
if (!post.success) throw new Error(post.error || "Failed to post");
|
||||
return post;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const promptName = process.argv[2];
|
||||
if (!promptName) {
|
||||
console.error("Usage: node bot.js <prompt-name>");
|
||||
console.error("Available:", loadPrompts().map((p) => p.name).join(", "));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const prompts = loadPrompts();
|
||||
const promptConfig = prompts.find((p) => p.name === promptName);
|
||||
if (!promptConfig) {
|
||||
console.error(`Prompt "${promptName}" not found. Available:`, prompts.map((p) => p.name).join(", "));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (!shouldRun(promptConfig)) {
|
||||
console.log(`"${promptName}" already hit its limit this week. Skipping.`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const kernel = new Kernel({ apiKey: process.env.KERNEL });
|
||||
const browser = await kernel.browsers.create({ stealth: true });
|
||||
const sessionId = browser.session_id;
|
||||
console.log(`[${promptName}] Session: ${sessionId}`);
|
||||
|
||||
try {
|
||||
const url = await login(kernel, sessionId);
|
||||
if (!url.includes("x.com/home")) {
|
||||
throw new Error("Login didn't reach home page: " + url);
|
||||
}
|
||||
|
||||
const history = loadHistory(promptName);
|
||||
const rawTweet = await generateTweet(promptConfig, history);
|
||||
const tweet = promptConfig.link ? rawTweet + "\n\n" + promptConfig.link : rawTweet;
|
||||
console.log(`[${promptName}] Tweet: "${tweet}"`);
|
||||
|
||||
await postTweet(kernel, sessionId, tweet);
|
||||
console.log(`[${promptName}] Posted!`);
|
||||
|
||||
history.push({ tweet: rawTweet, date: new Date().toISOString() });
|
||||
saveHistory(promptName, history);
|
||||
console.log(`[${promptName}] Total: ${history.length}`);
|
||||
} finally {
|
||||
try {
|
||||
await kernel.browsers.delete(sessionId);
|
||||
console.log("Session closed.");
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error("Failed:", err.message);
|
||||
process.exit(1);
|
||||
});
|
||||
Reference in New Issue
Block a user