From c9ca34a518af6d91eb7bd691ca573ba3d335fb75 Mon Sep 17 00:00:00 2001 From: BaiJunjie Date: Wed, 4 Feb 2026 07:27:04 +0900 Subject: [PATCH] Add X integration skill (#52) --- .claude/skills/x-integration/SKILL.md | 413 ++++++++++++++++++ .claude/skills/x-integration/agent.ts | 243 +++++++++++ .claude/skills/x-integration/host.ts | 159 +++++++ .claude/skills/x-integration/lib/browser.ts | 148 +++++++ .claude/skills/x-integration/lib/config.ts | 62 +++ .claude/skills/x-integration/scripts/like.ts | 56 +++ .claude/skills/x-integration/scripts/post.ts | 66 +++ .claude/skills/x-integration/scripts/quote.ts | 80 ++++ .claude/skills/x-integration/scripts/reply.ts | 74 ++++ .../skills/x-integration/scripts/retweet.ts | 62 +++ .claude/skills/x-integration/scripts/setup.ts | 87 ++++ 11 files changed, 1450 insertions(+) create mode 100644 .claude/skills/x-integration/SKILL.md create mode 100644 .claude/skills/x-integration/agent.ts create mode 100644 .claude/skills/x-integration/host.ts create mode 100644 .claude/skills/x-integration/lib/browser.ts create mode 100644 .claude/skills/x-integration/lib/config.ts create mode 100644 .claude/skills/x-integration/scripts/like.ts create mode 100644 .claude/skills/x-integration/scripts/post.ts create mode 100644 .claude/skills/x-integration/scripts/quote.ts create mode 100644 .claude/skills/x-integration/scripts/reply.ts create mode 100644 .claude/skills/x-integration/scripts/retweet.ts create mode 100644 .claude/skills/x-integration/scripts/setup.ts diff --git a/.claude/skills/x-integration/SKILL.md b/.claude/skills/x-integration/SKILL.md new file mode 100644 index 0000000..cd26614 --- /dev/null +++ b/.claude/skills/x-integration/SKILL.md @@ -0,0 +1,413 @@ +--- +name: x-integration +description: X (Twitter) integration for NanoClaw. Post tweets, like, reply, retweet, and quote. Use for setup, testing, or troubleshooting X functionality. Triggers on "setup x", "x integration", "twitter", "post tweet", "tweet". +--- + +# X (Twitter) Integration + +Browser automation for X interactions via WhatsApp. + +> **Compatibility:** NanoClaw v1.0.0. Directory structure may change in future versions. + +## Features + +| Action | Tool | Description | +|--------|------|-------------| +| Post | `x_post` | Publish new tweets | +| Like | `x_like` | Like any tweet | +| Reply | `x_reply` | Reply to tweets | +| Retweet | `x_retweet` | Retweet without comment | +| Quote | `x_quote` | Quote tweet with comment | + +## Prerequisites + +Before using this skill, ensure: + +1. **NanoClaw is installed and running** - WhatsApp connected, service active +2. **Dependencies installed**: + ```bash + npm ls playwright dotenv-cli || npm install playwright dotenv-cli + ``` +3. **CHROME_PATH configured** in `.env` (if Chrome is not at default location): + ```bash + # Find your Chrome path + mdfind "kMDItemCFBundleIdentifier == 'com.google.Chrome'" 2>/dev/null | head -1 + # Add to .env + CHROME_PATH=/path/to/Google Chrome.app/Contents/MacOS/Google Chrome + ``` + +## Quick Start + +```bash +# 1. Setup authentication (interactive) +npx dotenv -e .env -- npx tsx .claude/skills/x-integration/scripts/setup.ts +# Verify: data/x-auth.json should exist after successful login + +# 2. Rebuild container to include skill +./container/build.sh +# Verify: Output shows "COPY .claude/skills/x-integration/agent.ts" + +# 3. Rebuild host and restart service +npm run build +launchctl kickstart -k gui/$(id -u)/com.nanoclaw +# Verify: launchctl list | grep nanoclaw shows PID and exit code 0 +``` + +## Configuration + +### Environment Variables + +| Variable | Default | Description | +|----------|---------|-------------| +| `CHROME_PATH` | `/Applications/Google Chrome.app/Contents/MacOS/Google Chrome` | Chrome executable path | +| `NANOCLAW_ROOT` | `process.cwd()` | Project root directory | +| `LOG_LEVEL` | `info` | Logging level (debug, info, warn, error) | + +Set in `.env` file (loaded via `dotenv-cli` at runtime): + +```bash +# .env +CHROME_PATH=/Applications/Google Chrome.app/Contents/MacOS/Google Chrome +``` + +### Configuration File + +Edit `lib/config.ts` to modify defaults: + +```typescript +export const config = { + // Browser viewport + viewport: { width: 1280, height: 800 }, + + // Timeouts (milliseconds) + timeouts: { + navigation: 30000, // Page navigation + elementWait: 5000, // Wait for element + afterClick: 1000, // Delay after click + afterFill: 1000, // Delay after form fill + afterSubmit: 3000, // Delay after submit + pageLoad: 3000, // Initial page load + }, + + // Tweet limits + limits: { + tweetMaxLength: 280, + }, +}; +``` + +### Data Directories + +Paths relative to project root: + +| Path | Purpose | Git | +|------|---------|-----| +| `data/x-browser-profile/` | Chrome profile with X session | Ignored | +| `data/x-auth.json` | Auth state marker | Ignored | +| `logs/nanoclaw.log` | Service logs (contains X operation logs) | Ignored | + +## Architecture + +``` +┌─────────────────────────────────────────────────────────────┐ +│ Container (Linux VM) │ +│ └── agent.ts → MCP tool definitions (x_post, etc.) │ +│ └── Writes IPC request to /workspace/ipc/tasks/ │ +└──────────────────────┬──────────────────────────────────────┘ + │ IPC (file system) + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ Host (macOS) │ +│ └── src/index.ts → processTaskIpc() │ +│ └── host.ts → handleXIpc() │ +│ └── spawn subprocess → scripts/*.ts │ +│ └── Playwright → Chrome → X Website │ +└─────────────────────────────────────────────────────────────┘ +``` + +### Why This Design? + +- **API is expensive** - X official API requires paid subscription ($100+/month) for posting +- **Bot browsers get blocked** - X detects and bans headless browsers and common automation fingerprints +- **Must use user's real browser** - Reuses the user's actual Chrome on Host with real browser fingerprint to avoid detection +- **One-time authorization** - User logs in manually once, session persists in Chrome profile for future use + +### File Structure + +``` +.claude/skills/x-integration/ +├── SKILL.md # This documentation +├── host.ts # Host-side IPC handler +├── agent.ts # Container-side MCP tool definitions +├── lib/ +│ ├── config.ts # Centralized configuration +│ └── browser.ts # Playwright utilities +└── scripts/ + ├── setup.ts # Interactive login + ├── post.ts # Post tweet + ├── like.ts # Like tweet + ├── reply.ts # Reply to tweet + ├── retweet.ts # Retweet + └── quote.ts # Quote tweet +``` + +### Integration Points + +To integrate this skill into NanoClaw, make the following modifications: + +--- + +**1. Host side: `src/index.ts`** + +Add import after other local imports (look for `import { loadJson, saveJson, acquirePidLock } from './utils.js';`): +```typescript +import { handleXIpc } from '../.claude/skills/x-integration/host.js'; +``` + +Modify `processTaskIpc` function's switch statement default case: +```typescript +// Find: +default: +logger.warn({ type: data.type }, 'Unknown IPC task type'); + +// Replace with: +default: +const handled = await handleXIpc(data, sourceGroup, isMain, DATA_DIR); +if (!handled) { + logger.warn({ type: data.type }, 'Unknown IPC task type'); +} +``` + +--- + +**2. Container side: `container/agent-runner/src/ipc-mcp.ts`** + +Add import after `cron-parser` import: +```typescript +// @ts-ignore - Copied during Docker build from .claude/skills/x-integration/ +import { createXTools } from './skills/x-integration/agent.js'; +``` + +Add to the end of tools array (before the closing `]`): +```typescript + ...createXTools({ groupFolder, isMain }) +``` + +--- + +**3. Build script: `container/build.sh`** + +Change build context from `container/` to project root (required to access `.claude/skills/`): +```bash +# Find: +container build -t "${IMAGE_NAME}:${TAG}" . + +# Replace with: +cd "$SCRIPT_DIR/.." +container build -t "${IMAGE_NAME}:${TAG}" -f container/Dockerfile . +``` + +--- + +**4. Dockerfile: `container/Dockerfile`** + +First, update the build context paths (required to access `.claude/skills/` from project root): +```dockerfile +# Find: +COPY agent-runner/package*.json ./ +... +COPY agent-runner/ ./ + +# Replace with: +COPY container/agent-runner/package*.json ./ +... +COPY container/agent-runner/ ./ +``` + +Then add COPY line after `COPY container/agent-runner/ ./` and before `RUN npm run build`: +```dockerfile +# Copy skill MCP tools +COPY .claude/skills/x-integration/agent.ts ./src/skills/x-integration/ +``` + +## Setup + +All paths below are relative to project root (`NANOCLAW_ROOT`). + +### 1. Check Chrome Path + +```bash +# Check if Chrome exists at configured path +cat .env | grep CHROME_PATH +ls -la "$(grep CHROME_PATH .env | cut -d= -f2)" 2>/dev/null || \ +echo "Chrome not found - update CHROME_PATH in .env" +``` + +### 2. Run Authentication + +```bash +npx dotenv -e .env -- npx tsx .claude/skills/x-integration/scripts/setup.ts +``` + +This opens Chrome for manual X login. Session saved to `data/x-browser-profile/`. + +**Verify success:** +```bash +cat data/x-auth.json # Should show {"authenticated": true, ...} +``` + +### 3. Rebuild Container + +```bash +./container/build.sh +``` + +**Verify success:** +```bash +./container/build.sh 2>&1 | grep -i "agent.ts" # Should show COPY line +``` + +### 4. Restart Service + +```bash +npm run build +launchctl kickstart -k gui/$(id -u)/com.nanoclaw +``` + +**Verify success:** +```bash +launchctl list | grep nanoclaw # Should show PID and exit code 0 or - +``` + +## Usage via WhatsApp + +Replace `@Assistant` with your configured trigger name (`ASSISTANT_NAME` in `.env`): + +``` +@Assistant post a tweet: Hello world! + +@Assistant like this tweet https://x.com/user/status/123 + +@Assistant reply to https://x.com/user/status/123 with: Great post! + +@Assistant retweet https://x.com/user/status/123 + +@Assistant quote https://x.com/user/status/123 with comment: Interesting +``` + +**Note:** Only the main group can use X tools. Other groups will receive an error. + +## Testing + +Scripts require environment variables from `.env`. Use `dotenv-cli` to load them: + +### Check Authentication Status + +```bash +# Check if auth file exists and is valid +cat data/x-auth.json 2>/dev/null && echo "Auth configured" || echo "Auth not configured" + +# Check if browser profile exists +ls -la data/x-browser-profile/ 2>/dev/null | head -5 +``` + +### Re-authenticate (if expired) + +```bash +npx dotenv -e .env -- npx tsx .claude/skills/x-integration/scripts/setup.ts +``` + +### Test Post (will actually post) + +```bash +echo '{"content":"Test tweet - please ignore"}' | npx dotenv -e .env -- npx tsx .claude/skills/x-integration/scripts/post.ts +``` + +### Test Like + +```bash +echo '{"tweetUrl":"https://x.com/user/status/123"}' | npx dotenv -e .env -- npx tsx .claude/skills/x-integration/scripts/like.ts +``` + +Or export `CHROME_PATH` manually before running: + +```bash +export CHROME_PATH="/path/to/chrome" +echo '{"content":"Test"}' | npx tsx .claude/skills/x-integration/scripts/post.ts +``` + +## Troubleshooting + +### Authentication Expired + +```bash +npx dotenv -e .env -- npx tsx .claude/skills/x-integration/scripts/setup.ts +launchctl kickstart -k gui/$(id -u)/com.nanoclaw +``` + +### Browser Lock Files + +If Chrome fails to launch: + +```bash +rm -f data/x-browser-profile/SingletonLock +rm -f data/x-browser-profile/SingletonSocket +rm -f data/x-browser-profile/SingletonCookie +``` + +### Check Logs + +```bash +# Host logs (relative to project root) +grep -i "x_post\|x_like\|x_reply\|handleXIpc" logs/nanoclaw.log | tail -20 + +# Script errors +grep -i "error\|failed" logs/nanoclaw.log | tail -20 +``` + +### Script Timeout + +Default timeout is 2 minutes (120s). Increase in `host.ts`: + +```typescript +const timer = setTimeout(() => { + proc.kill('SIGTERM'); + resolve({ success: false, message: 'Script timed out (120s)' }); +}, 120000); // ← Increase this value +``` + +### X UI Selector Changes + +If X updates their UI, selectors in scripts may break. Current selectors: + +| Element | Selector | +|---------|----------| +| Tweet input | `[data-testid="tweetTextarea_0"]` | +| Post button | `[data-testid="tweetButtonInline"]` | +| Reply button | `[data-testid="reply"]` | +| Like | `[data-testid="like"]` | +| Unlike | `[data-testid="unlike"]` | +| Retweet | `[data-testid="retweet"]` | +| Unretweet | `[data-testid="unretweet"]` | +| Confirm retweet | `[data-testid="retweetConfirm"]` | +| Modal dialog | `[role="dialog"][aria-modal="true"]` | +| Modal submit | `[data-testid="tweetButton"]` | + +### Container Build Issues + +If MCP tools not found in container: + +```bash +# Verify build copies skill +./container/build.sh 2>&1 | grep -i skill + +# Check container has the file +container run nanoclaw-agent ls -la /app/src/skills/ +``` + +## Security + +- `data/x-browser-profile/` - Contains X session cookies (in `.gitignore`) +- `data/x-auth.json` - Auth state marker (in `.gitignore`) +- Only main group can use X tools (enforced in `agent.ts` and `host.ts`) +- Scripts run as subprocesses with limited environment \ No newline at end of file diff --git a/.claude/skills/x-integration/agent.ts b/.claude/skills/x-integration/agent.ts new file mode 100644 index 0000000..2b3ab5a --- /dev/null +++ b/.claude/skills/x-integration/agent.ts @@ -0,0 +1,243 @@ +/** + * X Integration - MCP Tool Definitions (Agent/Container Side) + * + * These tools run inside the container and communicate with the host via IPC. + * The host-side implementation is in host.ts. + * + * Note: This file is compiled in the container, not on the host. + * The @ts-ignore is needed because the SDK is only available in the container. + */ + +// @ts-ignore - SDK available in container environment only +import { tool } from '@anthropic-ai/claude-agent-sdk'; +import { z } from 'zod'; +import fs from 'fs'; +import path from 'path'; + +// IPC directories (inside container) +const IPC_DIR = '/workspace/ipc'; +const TASKS_DIR = path.join(IPC_DIR, 'tasks'); +const RESULTS_DIR = path.join(IPC_DIR, 'x_results'); + +function writeIpcFile(dir: string, data: object): string { + fs.mkdirSync(dir, { recursive: true }); + const filename = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}.json`; + const filepath = path.join(dir, filename); + const tempPath = `${filepath}.tmp`; + fs.writeFileSync(tempPath, JSON.stringify(data, null, 2)); + fs.renameSync(tempPath, filepath); + return filename; +} + +async function waitForResult(requestId: string, maxWait = 60000): Promise<{ success: boolean; message: string }> { + const resultFile = path.join(RESULTS_DIR, `${requestId}.json`); + const pollInterval = 1000; + let elapsed = 0; + + while (elapsed < maxWait) { + if (fs.existsSync(resultFile)) { + try { + const result = JSON.parse(fs.readFileSync(resultFile, 'utf-8')); + fs.unlinkSync(resultFile); + return result; + } catch (err) { + return { success: false, message: `Failed to read result: ${err}` }; + } + } + await new Promise(resolve => setTimeout(resolve, pollInterval)); + elapsed += pollInterval; + } + + return { success: false, message: 'Request timed out' }; +} + +export interface SkillToolsContext { + groupFolder: string; + isMain: boolean; +} + +/** + * Create X integration MCP tools + */ +export function createXTools(ctx: SkillToolsContext) { + const { groupFolder, isMain } = ctx; + + return [ + tool( + 'x_post', + `Post a tweet to X (Twitter). Main group only. + +The host machine will execute the browser automation to post the tweet. +Make sure the content is appropriate and within X's character limit (280 chars for text).`, + { + content: z.string().max(280).describe('The tweet content to post (max 280 characters)') + }, + async (args: { content: string }) => { + if (!isMain) { + return { + content: [{ type: 'text', text: 'Only the main group can post tweets.' }], + isError: true + }; + } + + if (args.content.length > 280) { + return { + content: [{ type: 'text', text: `Tweet exceeds 280 character limit (current: ${args.content.length})` }], + isError: true + }; + } + + const requestId = `xpost-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + writeIpcFile(TASKS_DIR, { + type: 'x_post', + requestId, + content: args.content, + groupFolder, + timestamp: new Date().toISOString() + }); + + const result = await waitForResult(requestId); + return { + content: [{ type: 'text', text: result.message }], + isError: !result.success + }; + } + ), + + tool( + 'x_like', + `Like a tweet on X (Twitter). Main group only. + +Provide the tweet URL or tweet ID to like.`, + { + tweet_url: z.string().describe('The tweet URL (e.g., https://x.com/user/status/123) or tweet ID') + }, + async (args: { tweet_url: string }) => { + if (!isMain) { + return { + content: [{ type: 'text', text: 'Only the main group can interact with X.' }], + isError: true + }; + } + + const requestId = `xlike-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + writeIpcFile(TASKS_DIR, { + type: 'x_like', + requestId, + tweetUrl: args.tweet_url, + groupFolder, + timestamp: new Date().toISOString() + }); + + const result = await waitForResult(requestId); + return { + content: [{ type: 'text', text: result.message }], + isError: !result.success + }; + } + ), + + tool( + 'x_reply', + `Reply to a tweet on X (Twitter). Main group only. + +Provide the tweet URL and your reply content.`, + { + tweet_url: z.string().describe('The tweet URL (e.g., https://x.com/user/status/123) or tweet ID'), + content: z.string().max(280).describe('The reply content (max 280 characters)') + }, + async (args: { tweet_url: string; content: string }) => { + if (!isMain) { + return { + content: [{ type: 'text', text: 'Only the main group can interact with X.' }], + isError: true + }; + } + + const requestId = `xreply-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + writeIpcFile(TASKS_DIR, { + type: 'x_reply', + requestId, + tweetUrl: args.tweet_url, + content: args.content, + groupFolder, + timestamp: new Date().toISOString() + }); + + const result = await waitForResult(requestId); + return { + content: [{ type: 'text', text: result.message }], + isError: !result.success + }; + } + ), + + tool( + 'x_retweet', + `Retweet a tweet on X (Twitter). Main group only. + +Provide the tweet URL to retweet.`, + { + tweet_url: z.string().describe('The tweet URL (e.g., https://x.com/user/status/123) or tweet ID') + }, + async (args: { tweet_url: string }) => { + if (!isMain) { + return { + content: [{ type: 'text', text: 'Only the main group can interact with X.' }], + isError: true + }; + } + + const requestId = `xretweet-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + writeIpcFile(TASKS_DIR, { + type: 'x_retweet', + requestId, + tweetUrl: args.tweet_url, + groupFolder, + timestamp: new Date().toISOString() + }); + + const result = await waitForResult(requestId); + return { + content: [{ type: 'text', text: result.message }], + isError: !result.success + }; + } + ), + + tool( + 'x_quote', + `Quote tweet on X (Twitter). Main group only. + +Retweet with your own comment added.`, + { + tweet_url: z.string().describe('The tweet URL (e.g., https://x.com/user/status/123) or tweet ID'), + comment: z.string().max(280).describe('Your comment for the quote tweet (max 280 characters)') + }, + async (args: { tweet_url: string; comment: string }) => { + if (!isMain) { + return { + content: [{ type: 'text', text: 'Only the main group can interact with X.' }], + isError: true + }; + } + + const requestId = `xquote-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + writeIpcFile(TASKS_DIR, { + type: 'x_quote', + requestId, + tweetUrl: args.tweet_url, + comment: args.comment, + groupFolder, + timestamp: new Date().toISOString() + }); + + const result = await waitForResult(requestId); + return { + content: [{ type: 'text', text: result.message }], + isError: !result.success + }; + } + ) + ]; +} diff --git a/.claude/skills/x-integration/host.ts b/.claude/skills/x-integration/host.ts new file mode 100644 index 0000000..a56269d --- /dev/null +++ b/.claude/skills/x-integration/host.ts @@ -0,0 +1,159 @@ +/** + * X Integration IPC Handler + * + * Handles all x_* IPC messages from container agents. + * This is the entry point for X integration in the host process. + */ + +import { spawn } from 'child_process'; +import fs from 'fs'; +import path from 'path'; +import pino from 'pino'; + +const logger = pino({ + level: process.env.LOG_LEVEL || 'info', + transport: { target: 'pino-pretty', options: { colorize: true } } +}); + +interface SkillResult { + success: boolean; + message: string; + data?: unknown; +} + +// Run a skill script as subprocess +async function runScript(script: string, args: object): Promise { + const scriptPath = path.join(process.cwd(), '.claude', 'skills', 'x-integration', 'scripts', `${script}.ts`); + + return new Promise((resolve) => { + const proc = spawn('npx', ['tsx', scriptPath], { + cwd: process.cwd(), + env: { ...process.env, NANOCLAW_ROOT: process.cwd() }, + stdio: ['pipe', 'pipe', 'pipe'] + }); + + let stdout = ''; + proc.stdout.on('data', (data) => { stdout += data.toString(); }); + proc.stdin.write(JSON.stringify(args)); + proc.stdin.end(); + + const timer = setTimeout(() => { + proc.kill('SIGTERM'); + resolve({ success: false, message: 'Script timed out (120s)' }); + }, 120000); + + proc.on('close', (code) => { + clearTimeout(timer); + if (code !== 0) { + resolve({ success: false, message: `Script exited with code: ${code}` }); + return; + } + try { + const lines = stdout.trim().split('\n'); + resolve(JSON.parse(lines[lines.length - 1])); + } catch { + resolve({ success: false, message: `Failed to parse output: ${stdout.slice(0, 200)}` }); + } + }); + + proc.on('error', (err) => { + clearTimeout(timer); + resolve({ success: false, message: `Failed to spawn: ${err.message}` }); + }); + }); +} + +// Write result to IPC results directory +function writeResult(dataDir: string, sourceGroup: string, requestId: string, result: SkillResult): void { + const resultsDir = path.join(dataDir, 'ipc', sourceGroup, 'x_results'); + fs.mkdirSync(resultsDir, { recursive: true }); + fs.writeFileSync(path.join(resultsDir, `${requestId}.json`), JSON.stringify(result)); +} + +/** + * Handle X integration IPC messages + * + * @returns true if message was handled, false if not an X message + */ +export async function handleXIpc( + data: Record, + sourceGroup: string, + isMain: boolean, + dataDir: string +): Promise { + const type = data.type as string; + + // Only handle x_* types + if (!type?.startsWith('x_')) { + return false; + } + + // Only main group can use X integration + if (!isMain) { + logger.warn({ sourceGroup, type }, 'X integration blocked: not main group'); + return true; + } + + const requestId = data.requestId as string; + if (!requestId) { + logger.warn({ type }, 'X integration blocked: missing requestId'); + return true; + } + + logger.info({ type, requestId }, 'Processing X request'); + + let result: SkillResult; + + switch (type) { + case 'x_post': + if (!data.content) { + result = { success: false, message: 'Missing content' }; + break; + } + result = await runScript('post', { content: data.content }); + break; + + case 'x_like': + if (!data.tweetUrl) { + result = { success: false, message: 'Missing tweetUrl' }; + break; + } + result = await runScript('like', { tweetUrl: data.tweetUrl }); + break; + + case 'x_reply': + if (!data.tweetUrl || !data.content) { + result = { success: false, message: 'Missing tweetUrl or content' }; + break; + } + result = await runScript('reply', { tweetUrl: data.tweetUrl, content: data.content }); + break; + + case 'x_retweet': + if (!data.tweetUrl) { + result = { success: false, message: 'Missing tweetUrl' }; + break; + } + result = await runScript('retweet', { tweetUrl: data.tweetUrl }); + break; + + case 'x_quote': + if (!data.tweetUrl || !data.comment) { + result = { success: false, message: 'Missing tweetUrl or comment' }; + break; + } + result = await runScript('quote', { tweetUrl: data.tweetUrl, comment: data.comment }); + break; + + default: + return false; + } + + writeResult(dataDir, sourceGroup, requestId, result); + if (result.success) { + logger.info({ type, requestId }, 'X request completed'); + } else { + logger.error({ type, requestId, message: result.message }, 'X request failed'); + } + return true; +} diff --git a/.claude/skills/x-integration/lib/browser.ts b/.claude/skills/x-integration/lib/browser.ts new file mode 100644 index 0000000..a7868cc --- /dev/null +++ b/.claude/skills/x-integration/lib/browser.ts @@ -0,0 +1,148 @@ +/** + * X Integration - Shared utilities + * Used by all X scripts + */ + +import { chromium, BrowserContext, Page } from 'playwright'; +import fs from 'fs'; +import path from 'path'; +import { config } from './config.js'; + +export { config }; + +export interface ScriptResult { + success: boolean; + message: string; + data?: unknown; +} + +/** + * Read input from stdin + */ +export async function readInput(): Promise { + return new Promise((resolve, reject) => { + let data = ''; + process.stdin.setEncoding('utf8'); + process.stdin.on('data', chunk => { data += chunk; }); + process.stdin.on('end', () => { + try { + resolve(JSON.parse(data)); + } catch (err) { + reject(new Error(`Invalid JSON input: ${err}`)); + } + }); + process.stdin.on('error', reject); + }); +} + +/** + * Write result to stdout + */ +export function writeResult(result: ScriptResult): void { + console.log(JSON.stringify(result)); +} + +/** + * Clean up browser lock files + */ +export function cleanupLockFiles(): void { + for (const lockFile of ['SingletonLock', 'SingletonSocket', 'SingletonCookie']) { + const lockPath = path.join(config.browserDataDir, lockFile); + if (fs.existsSync(lockPath)) { + try { fs.unlinkSync(lockPath); } catch {} + } + } +} + +/** + * Validate tweet/reply content + */ +export function validateContent(content: string | undefined, type = 'Tweet'): ScriptResult | null { + if (!content || content.length === 0) { + return { success: false, message: `${type} content cannot be empty` }; + } + if (content.length > config.limits.tweetMaxLength) { + return { success: false, message: `${type} exceeds ${config.limits.tweetMaxLength} character limit (current: ${content.length})` }; + } + return null; // Valid +} + +/** + * Get browser context with persistent profile + */ +export async function getBrowserContext(): Promise { + if (!fs.existsSync(config.authPath)) { + throw new Error('X authentication not configured. Run /x-integration to complete login.'); + } + + cleanupLockFiles(); + + const context = await chromium.launchPersistentContext(config.browserDataDir, { + executablePath: config.chromePath, + headless: false, + viewport: config.viewport, + args: config.chromeArgs, + ignoreDefaultArgs: config.chromeIgnoreDefaultArgs, + }); + + return context; +} + +/** + * Extract tweet ID from URL or raw ID + */ +export function extractTweetId(input: string): string | null { + const urlMatch = input.match(/(?:x\.com|twitter\.com)\/\w+\/status\/(\d+)/); + if (urlMatch) return urlMatch[1]; + if (/^\d+$/.test(input.trim())) return input.trim(); + return null; +} + +/** + * Navigate to a tweet page + */ +export async function navigateToTweet( + context: BrowserContext, + tweetUrl: string +): Promise<{ page: Page; success: boolean; error?: string }> { + const page = context.pages()[0] || await context.newPage(); + + let url = tweetUrl; + const tweetId = extractTweetId(tweetUrl); + if (tweetId && !tweetUrl.startsWith('http')) { + url = `https://x.com/i/status/${tweetId}`; + } + + try { + await page.goto(url, { timeout: config.timeouts.navigation, waitUntil: 'domcontentloaded' }); + await page.waitForTimeout(config.timeouts.pageLoad); + + const exists = await page.locator('article[data-testid="tweet"]').first().isVisible().catch(() => false); + if (!exists) { + return { page, success: false, error: 'Tweet not found. It may have been deleted or the URL is invalid.' }; + } + + return { page, success: true }; + } catch (err) { + return { page, success: false, error: `Navigation failed: ${err instanceof Error ? err.message : String(err)}` }; + } +} + +/** + * Run script with error handling + */ +export async function runScript( + handler: (input: T) => Promise +): Promise { + try { + const input = await readInput(); + const result = await handler(input); + writeResult(result); + } catch (err) { + writeResult({ + success: false, + message: `Script execution failed: ${err instanceof Error ? err.message : String(err)}` + }); + process.exit(1); + } +} diff --git a/.claude/skills/x-integration/lib/config.ts b/.claude/skills/x-integration/lib/config.ts new file mode 100644 index 0000000..f56cde4 --- /dev/null +++ b/.claude/skills/x-integration/lib/config.ts @@ -0,0 +1,62 @@ +/** + * X Integration - Configuration + * + * All environment-specific settings in one place. + * Override via environment variables or modify defaults here. + */ + +import path from 'path'; + +// Project root - can be overridden for different deployments +const PROJECT_ROOT = process.env.NANOCLAW_ROOT || process.cwd(); + +/** + * Configuration object with all settings + */ +export const config = { + // Chrome executable path + // Default: standard macOS Chrome location + // Override: CHROME_PATH environment variable + chromePath: process.env.CHROME_PATH || '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', + + // Browser profile directory for persistent login sessions + browserDataDir: path.join(PROJECT_ROOT, 'data', 'x-browser-profile'), + + // Auth state marker file + authPath: path.join(PROJECT_ROOT, 'data', 'x-auth.json'), + + // Browser viewport settings + viewport: { + width: 1280, + height: 800, + }, + + // Timeouts (in milliseconds) + timeouts: { + navigation: 30000, + elementWait: 5000, + afterClick: 1000, + afterFill: 1000, + afterSubmit: 3000, + pageLoad: 3000, + }, + + // X character limits + limits: { + tweetMaxLength: 280, + }, + + // Chrome launch arguments + chromeArgs: [ + '--disable-blink-features=AutomationControlled', + '--no-sandbox', + '--disable-setuid-sandbox', + '--no-first-run', + '--no-default-browser-check', + '--disable-sync', + ], + + // Args to ignore when launching Chrome + chromeIgnoreDefaultArgs: ['--enable-automation'], +}; + diff --git a/.claude/skills/x-integration/scripts/like.ts b/.claude/skills/x-integration/scripts/like.ts new file mode 100644 index 0000000..c55b8b4 --- /dev/null +++ b/.claude/skills/x-integration/scripts/like.ts @@ -0,0 +1,56 @@ +#!/usr/bin/env npx tsx +/** + * X Integration - Like Tweet + * Usage: echo '{"tweetUrl":"https://x.com/user/status/123"}' | npx tsx like.ts + */ + +import { getBrowserContext, navigateToTweet, runScript, config, ScriptResult } from '../lib/browser.js'; + +interface LikeInput { + tweetUrl: string; +} + +async function likeTweet(input: LikeInput): Promise { + const { tweetUrl } = input; + + if (!tweetUrl) { + return { success: false, message: 'Please provide a tweet URL' }; + } + + let context = null; + try { + context = await getBrowserContext(); + const { page, success, error } = await navigateToTweet(context, tweetUrl); + + if (!success) { + return { success: false, message: error || 'Navigation failed' }; + } + + const tweet = page.locator('article[data-testid="tweet"]').first(); + const unlikeButton = tweet.locator('[data-testid="unlike"]'); + const likeButton = tweet.locator('[data-testid="like"]'); + + // Check if already liked + const alreadyLiked = await unlikeButton.isVisible().catch(() => false); + if (alreadyLiked) { + return { success: true, message: 'Tweet already liked' }; + } + + await likeButton.waitFor({ timeout: config.timeouts.elementWait }); + await likeButton.click(); + await page.waitForTimeout(config.timeouts.afterClick); + + // Verify + const nowLiked = await unlikeButton.isVisible().catch(() => false); + if (nowLiked) { + return { success: true, message: 'Like successful' }; + } + + return { success: false, message: 'Like action completed but could not verify success' }; + + } finally { + if (context) await context.close(); + } +} + +runScript(likeTweet); diff --git a/.claude/skills/x-integration/scripts/post.ts b/.claude/skills/x-integration/scripts/post.ts new file mode 100644 index 0000000..f7b47dc --- /dev/null +++ b/.claude/skills/x-integration/scripts/post.ts @@ -0,0 +1,66 @@ +#!/usr/bin/env npx tsx +/** + * X Integration - Post Tweet + * Usage: echo '{"content":"Hello world"}' | npx tsx post.ts + */ + +import { getBrowserContext, runScript, validateContent, config, ScriptResult } from '../lib/browser.js'; + +interface PostInput { + content: string; +} + +async function postTweet(input: PostInput): Promise { + const { content } = input; + + const validationError = validateContent(content, 'Tweet'); + if (validationError) return validationError; + + let context = null; + try { + context = await getBrowserContext(); + const page = context.pages()[0] || await context.newPage(); + + await page.goto('https://x.com/home', { timeout: config.timeouts.navigation, waitUntil: 'domcontentloaded' }); + await page.waitForTimeout(config.timeouts.pageLoad); + + // Check if logged in + const isLoggedIn = await page.locator('[data-testid="SideNav_AccountSwitcher_Button"]').isVisible().catch(() => false); + if (!isLoggedIn) { + const onLoginPage = await page.locator('input[autocomplete="username"]').isVisible().catch(() => false); + if (onLoginPage) { + return { success: false, message: 'X login expired. Run /x-integration to re-authenticate.' }; + } + } + + // Find and fill tweet input + const tweetInput = page.locator('[data-testid="tweetTextarea_0"]'); + await tweetInput.waitFor({ timeout: config.timeouts.elementWait * 2 }); + await tweetInput.click(); + await page.waitForTimeout(config.timeouts.afterClick / 2); + await tweetInput.fill(content); + await page.waitForTimeout(config.timeouts.afterFill); + + // Click post button + const postButton = page.locator('[data-testid="tweetButtonInline"]'); + await postButton.waitFor({ timeout: config.timeouts.elementWait }); + + const isDisabled = await postButton.getAttribute('aria-disabled'); + if (isDisabled === 'true') { + return { success: false, message: 'Post button disabled. Content may be empty or exceed character limit.' }; + } + + await postButton.click(); + await page.waitForTimeout(config.timeouts.afterSubmit); + + return { + success: true, + message: `Tweet posted: ${content.slice(0, 50)}${content.length > 50 ? '...' : ''}` + }; + + } finally { + if (context) await context.close(); + } +} + +runScript(postTweet); diff --git a/.claude/skills/x-integration/scripts/quote.ts b/.claude/skills/x-integration/scripts/quote.ts new file mode 100644 index 0000000..e0d2c33 --- /dev/null +++ b/.claude/skills/x-integration/scripts/quote.ts @@ -0,0 +1,80 @@ +#!/usr/bin/env npx tsx +/** + * X Integration - Quote Tweet + * Usage: echo '{"tweetUrl":"https://x.com/user/status/123","comment":"My thoughts"}' | npx tsx quote.ts + */ + +import { getBrowserContext, navigateToTweet, runScript, validateContent, config, ScriptResult } from '../lib/browser.js'; + +interface QuoteInput { + tweetUrl: string; + comment: string; +} + +async function quoteTweet(input: QuoteInput): Promise { + const { tweetUrl, comment } = input; + + if (!tweetUrl) { + return { success: false, message: 'Please provide a tweet URL' }; + } + + const validationError = validateContent(comment, 'Comment'); + if (validationError) return validationError; + + let context = null; + try { + context = await getBrowserContext(); + const { page, success, error } = await navigateToTweet(context, tweetUrl); + + if (!success) { + return { success: false, message: error || 'Navigation failed' }; + } + + // Click retweet button to open menu + const tweet = page.locator('article[data-testid="tweet"]').first(); + const retweetButton = tweet.locator('[data-testid="retweet"]'); + await retweetButton.waitFor({ timeout: config.timeouts.elementWait }); + await retweetButton.click(); + await page.waitForTimeout(config.timeouts.afterClick); + + // Click quote option + const quoteOption = page.getByRole('menuitem').filter({ hasText: /Quote/i }); + await quoteOption.waitFor({ timeout: config.timeouts.elementWait }); + await quoteOption.click(); + await page.waitForTimeout(config.timeouts.afterClick * 1.5); + + // Find dialog with aria-modal="true" + const dialog = page.locator('[role="dialog"][aria-modal="true"]'); + await dialog.waitFor({ timeout: config.timeouts.elementWait }); + + // Fill comment + const quoteInput = dialog.locator('[data-testid="tweetTextarea_0"]'); + await quoteInput.waitFor({ timeout: config.timeouts.elementWait }); + await quoteInput.click(); + await page.waitForTimeout(config.timeouts.afterClick / 2); + await quoteInput.fill(comment); + await page.waitForTimeout(config.timeouts.afterFill); + + // Click submit button + const submitButton = dialog.locator('[data-testid="tweetButton"]'); + await submitButton.waitFor({ timeout: config.timeouts.elementWait }); + + const isDisabled = await submitButton.getAttribute('aria-disabled'); + if (isDisabled === 'true') { + return { success: false, message: 'Submit button disabled. Content may be empty or exceed character limit.' }; + } + + await submitButton.click(); + await page.waitForTimeout(config.timeouts.afterSubmit); + + return { + success: true, + message: `Quote tweet posted: ${comment.slice(0, 50)}${comment.length > 50 ? '...' : ''}` + }; + + } finally { + if (context) await context.close(); + } +} + +runScript(quoteTweet); diff --git a/.claude/skills/x-integration/scripts/reply.ts b/.claude/skills/x-integration/scripts/reply.ts new file mode 100644 index 0000000..e981cab --- /dev/null +++ b/.claude/skills/x-integration/scripts/reply.ts @@ -0,0 +1,74 @@ +#!/usr/bin/env npx tsx +/** + * X Integration - Reply to Tweet + * Usage: echo '{"tweetUrl":"https://x.com/user/status/123","content":"Great post!"}' | npx tsx reply.ts + */ + +import { getBrowserContext, navigateToTweet, runScript, validateContent, config, ScriptResult } from '../lib/browser.js'; + +interface ReplyInput { + tweetUrl: string; + content: string; +} + +async function replyToTweet(input: ReplyInput): Promise { + const { tweetUrl, content } = input; + + if (!tweetUrl) { + return { success: false, message: 'Please provide a tweet URL' }; + } + + const validationError = validateContent(content, 'Reply'); + if (validationError) return validationError; + + let context = null; + try { + context = await getBrowserContext(); + const { page, success, error } = await navigateToTweet(context, tweetUrl); + + if (!success) { + return { success: false, message: error || 'Navigation failed' }; + } + + // Click reply button + const tweet = page.locator('article[data-testid="tweet"]').first(); + const replyButton = tweet.locator('[data-testid="reply"]'); + await replyButton.waitFor({ timeout: config.timeouts.elementWait }); + await replyButton.click(); + await page.waitForTimeout(config.timeouts.afterClick * 1.5); + + // Find dialog with aria-modal="true" to avoid matching other dialogs + const dialog = page.locator('[role="dialog"][aria-modal="true"]'); + await dialog.waitFor({ timeout: config.timeouts.elementWait }); + + // Fill reply content + const replyInput = dialog.locator('[data-testid="tweetTextarea_0"]'); + await replyInput.waitFor({ timeout: config.timeouts.elementWait }); + await replyInput.click(); + await page.waitForTimeout(config.timeouts.afterClick / 2); + await replyInput.fill(content); + await page.waitForTimeout(config.timeouts.afterFill); + + // Click submit button + const submitButton = dialog.locator('[data-testid="tweetButton"]'); + await submitButton.waitFor({ timeout: config.timeouts.elementWait }); + + const isDisabled = await submitButton.getAttribute('aria-disabled'); + if (isDisabled === 'true') { + return { success: false, message: 'Submit button disabled. Content may be empty or exceed character limit.' }; + } + + await submitButton.click(); + await page.waitForTimeout(config.timeouts.afterSubmit); + + return { + success: true, + message: `Reply posted: ${content.slice(0, 50)}${content.length > 50 ? '...' : ''}` + }; + + } finally { + if (context) await context.close(); + } +} + +runScript(replyToTweet); diff --git a/.claude/skills/x-integration/scripts/retweet.ts b/.claude/skills/x-integration/scripts/retweet.ts new file mode 100644 index 0000000..05b7437 --- /dev/null +++ b/.claude/skills/x-integration/scripts/retweet.ts @@ -0,0 +1,62 @@ +#!/usr/bin/env npx tsx +/** + * X Integration - Retweet + * Usage: echo '{"tweetUrl":"https://x.com/user/status/123"}' | npx tsx retweet.ts + */ + +import { getBrowserContext, navigateToTweet, runScript, config, ScriptResult } from '../lib/browser.js'; + +interface RetweetInput { + tweetUrl: string; +} + +async function retweet(input: RetweetInput): Promise { + const { tweetUrl } = input; + + if (!tweetUrl) { + return { success: false, message: 'Please provide a tweet URL' }; + } + + let context = null; + try { + context = await getBrowserContext(); + const { page, success, error } = await navigateToTweet(context, tweetUrl); + + if (!success) { + return { success: false, message: error || 'Navigation failed' }; + } + + const tweet = page.locator('article[data-testid="tweet"]').first(); + const unretweetButton = tweet.locator('[data-testid="unretweet"]'); + const retweetButton = tweet.locator('[data-testid="retweet"]'); + + // Check if already retweeted + const alreadyRetweeted = await unretweetButton.isVisible().catch(() => false); + if (alreadyRetweeted) { + return { success: true, message: 'Tweet already retweeted' }; + } + + await retweetButton.waitFor({ timeout: config.timeouts.elementWait }); + await retweetButton.click(); + await page.waitForTimeout(config.timeouts.afterClick); + + // Click retweet confirm option + const retweetConfirm = page.locator('[data-testid="retweetConfirm"]'); + await retweetConfirm.waitFor({ timeout: config.timeouts.elementWait }); + await retweetConfirm.click(); + await page.waitForTimeout(config.timeouts.afterClick * 2); + + // Verify + const nowRetweeted = await unretweetButton.isVisible().catch(() => false); + if (nowRetweeted) { + return { success: true, message: 'Retweet successful' }; + } + + return { success: false, message: 'Retweet action completed but could not verify success' }; + + } finally { + if (context) await context.close(); + } +} + +runScript(retweet); diff --git a/.claude/skills/x-integration/scripts/setup.ts b/.claude/skills/x-integration/scripts/setup.ts new file mode 100644 index 0000000..94e5c03 --- /dev/null +++ b/.claude/skills/x-integration/scripts/setup.ts @@ -0,0 +1,87 @@ +#!/usr/bin/env npx tsx +/** + * X Integration - Authentication Setup + * Usage: npx tsx setup.ts + * + * Interactive script - opens browser for manual login + */ + +import { chromium } from 'playwright'; +import * as readline from 'readline'; +import fs from 'fs'; +import path from 'path'; +import { config, cleanupLockFiles } from '../lib/browser.js'; + +async function setup(): Promise { + console.log('=== X (Twitter) Authentication Setup ===\n'); + console.log('This will open Chrome for you to log in to X.'); + console.log('Your login session will be saved for automated interactions.\n'); + console.log(`Chrome path: ${config.chromePath}`); + console.log(`Profile dir: ${config.browserDataDir}\n`); + + // Ensure directories exist + fs.mkdirSync(path.dirname(config.authPath), { recursive: true }); + fs.mkdirSync(config.browserDataDir, { recursive: true }); + + cleanupLockFiles(); + + console.log('Launching browser...\n'); + + const context = await chromium.launchPersistentContext(config.browserDataDir, { + executablePath: config.chromePath, + headless: false, + viewport: config.viewport, + args: config.chromeArgs.slice(0, 3), // Use first 3 args for setup (less restrictive) + ignoreDefaultArgs: config.chromeIgnoreDefaultArgs, + }); + + const page = context.pages()[0] || await context.newPage(); + + // Navigate to login page + await page.goto('https://x.com/login'); + + console.log('Please log in to X in the browser window.'); + console.log('After you see your home feed, come back here and press Enter.\n'); + + // Wait for user to complete login + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout + }); + + await new Promise(resolve => { + rl.question('Press Enter when logged in... ', () => { + rl.close(); + resolve(); + }); + }); + + // Verify login by navigating to home and checking for account button + console.log('\nVerifying login status...'); + await page.goto('https://x.com/home'); + await page.waitForTimeout(config.timeouts.pageLoad); + + const isLoggedIn = await page.locator('[data-testid="SideNav_AccountSwitcher_Button"]').isVisible().catch(() => false); + + if (isLoggedIn) { + // Save auth marker + fs.writeFileSync(config.authPath, JSON.stringify({ + authenticated: true, + timestamp: new Date().toISOString() + }, null, 2)); + + console.log('\n✅ Authentication successful!'); + console.log(`Session saved to: ${config.browserDataDir}`); + console.log('\nYou can now use X integration features.'); + } else { + console.log('\n❌ Could not verify login status.'); + console.log('Please try again and make sure you are logged in to X.'); + } + + await context.close(); +} + +setup().catch(err => { + console.error('Setup failed:', err.message); + process.exit(1); +});