Files
xBot/bot.js
Matthew Jackson dacc7604cc Migrate to Docker: containerize for docker-server deployment
- Add Dockerfile + cron.js (daily 4pm UTC loop replacing EC2 cron)
- Add infra/docker-compose.yml and deploy-stack.sh for Portainer
- Support DATA_DIR env var in bot.js for persistent history volume
- Support PROMPTS_JSON env var in cron.js (no SSH needed for config)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-17 17:15:18 -07:00

305 lines
10 KiB
JavaScript

import "dotenv/config";
import Kernel from "@onkernel/sdk";
import { readFileSync, writeFileSync } 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");
const DATA_DIR = process.env.DATA_DIR || __dirname;
function loadPrompts() {
return JSON.parse(readFileSync(PROMPTS_FILE, "utf-8"));
}
function historyPath(name) {
return join(DATA_DIR, `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 schedule = promptConfig.schedule;
if (!schedule || schedule.type === "daily") return true;
if (schedule.type === "random" && schedule.minDays) {
const history = loadHistory(promptConfig.name);
if (history.length > 0) {
const now = new Date();
const lastPost = new Date(history[history.length - 1].date);
const daysSinceLast = (now - lastPost) / (1000 * 60 * 60 * 24);
if (daysSinceLast < schedule.minDays) return false;
}
// Random chance so it doesn't always fire on the minimum day
return Math.random() < 0.5;
}
return true;
}
function buildPrompt(promptConfig, history) {
let prompt = promptConfig.prompt;
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, account) {
const prefix = account.toUpperCase();
const xAcct = JSON.stringify(process.env[`${prefix}_USER`]);
const xPw = JSON.stringify(process.env[`${prefix}_PW`]);
const xEmail = JSON.stringify(process.env[`${prefix}_EMAIL`]);
const xPhone = JSON.stringify(process.env[`${prefix}_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 page.waitForTimeout(300);
await page.keyboard.type(${xAcct}, { delay: 50 });
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);
// Adaptive login flow — keep reading the page and responding
for (let step = 0; step < 5; step++) {
const pageState = await readPage(kernel, sessionId);
const pageText = pageState.texts.join(" ").toLowerCase();
const inputTypes = pageState.inputs.map((i) => i.type);
console.log(`Step ${step}: url=${pageState.url}, inputs=[${inputTypes}], text=${pageText.slice(0, 200)}`);
// Password field visible — we're ready to enter password
if (inputTypes.includes("password")) {
console.log("Password field found");
break;
}
// Determine what to type based on what the page is asking
let value;
if (pageText.includes("phone") && pageText.includes("email")) {
value = xPhone;
console.log("Page asks for phone or email — using phone");
} else if (pageText.includes("phone")) {
value = xPhone;
console.log("Page asks for phone");
} else if (pageText.includes("email")) {
value = xEmail;
console.log("Page asks for email");
} else if (pageText.includes("username")) {
value = xAcct;
console.log("Page asks for username");
} else {
console.log("Unknown page state, trying phone");
value = xPhone;
}
const rv = await exec(kernel, sessionId, `
const inputs = await page.locator('input:visible').all();
const input = inputs[inputs.length - 1];
await input.click();
await input.clear();
await page.waitForTimeout(300);
await page.keyboard.type(${value}, { delay: 50 });
await page.waitForTimeout(500);
const nextBtn = page.locator('[role="button"]').filter({ hasText: /Next|Log in|Continue/i });
if (await nextBtn.count() > 0) {
await nextBtn.first().click();
}
await page.waitForTimeout(5000);
`, 30);
if (!rv.success) throw new Error(`Step ${step} failed: ` + rv.error);
}
const finalState = await readPage(kernel, sessionId);
if (!finalState.inputs.some((i) => i.type === "password")) {
console.log("Final state:", JSON.stringify(finalState, null, 2));
throw new Error("Never reached password field");
}
const r2 = await exec(kernel, sessionId, `
const input = page.locator('input[type="password"]');
await input.waitFor({ state: 'visible', timeout: 10000 });
await input.click();
await page.waitForTimeout(500);
await page.keyboard.type(${xPw}, { delay: 50 });
await page.waitForTimeout(500);
const loginBtn = page.getByTestId('LoginForm_Login_Button');
await loginBtn.click();
await page.waitForTimeout(8000);
return page.url();
`, 45);
if (!r2.success) throw new Error("Password failed: " + r2.error);
console.log("Logged in:", r2.result);
return r2.result;
}
async function postTweet(kernel, sessionId, tweetText) {
const composeResult = await exec(kernel, sessionId, `
await page.goto('https://x.com/compose/post', { waitUntil: 'domcontentloaded', timeout: 15000 });
await page.waitForTimeout(3000);
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}" skipped (too soon or random skip). Next time.`);
process.exit(0);
}
const kernel = new Kernel({ apiKey: process.env.KERNEL });
// Clean up any stale sessions
try {
const sessions = await kernel.browsers.list();
if (sessions.length > 0) {
console.log(`Cleaning up ${sessions.length} stale session(s)...`);
for (const s of sessions) {
try { await kernel.browsers.delete(s.session_id); } catch {}
}
}
} catch {}
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, promptConfig.account);
if (!url.includes("x.com")) {
throw new Error("Login failed: " + url);
}
const history = loadHistory(promptName);
const rawTweet = await generateTweet(promptConfig, history);
let tweet = rawTweet;
if (promptConfig.links) {
for (const [key, linkUrl] of Object.entries(promptConfig.links)) {
tweet = tweet.replaceAll(`<${key}>`, linkUrl);
}
}
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);
});