Add X integration skill (#52)
This commit is contained in:
413
.claude/skills/x-integration/SKILL.md
Normal file
413
.claude/skills/x-integration/SKILL.md
Normal file
@@ -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
|
||||||
243
.claude/skills/x-integration/agent.ts
Normal file
243
.claude/skills/x-integration/agent.ts
Normal file
@@ -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
|
||||||
|
};
|
||||||
|
}
|
||||||
|
)
|
||||||
|
];
|
||||||
|
}
|
||||||
159
.claude/skills/x-integration/host.ts
Normal file
159
.claude/skills/x-integration/host.ts
Normal file
@@ -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<SkillResult> {
|
||||||
|
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<string, unknown>,
|
||||||
|
sourceGroup: string,
|
||||||
|
isMain: boolean,
|
||||||
|
dataDir: string
|
||||||
|
): Promise<boolean> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
148
.claude/skills/x-integration/lib/browser.ts
Normal file
148
.claude/skills/x-integration/lib/browser.ts
Normal file
@@ -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<T>(): Promise<T> {
|
||||||
|
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<BrowserContext> {
|
||||||
|
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<T>(
|
||||||
|
handler: (input: T) => Promise<ScriptResult>
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
const input = await readInput<T>();
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
62
.claude/skills/x-integration/lib/config.ts
Normal file
62
.claude/skills/x-integration/lib/config.ts
Normal file
@@ -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'],
|
||||||
|
};
|
||||||
|
|
||||||
56
.claude/skills/x-integration/scripts/like.ts
Normal file
56
.claude/skills/x-integration/scripts/like.ts
Normal file
@@ -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<ScriptResult> {
|
||||||
|
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<LikeInput>(likeTweet);
|
||||||
66
.claude/skills/x-integration/scripts/post.ts
Normal file
66
.claude/skills/x-integration/scripts/post.ts
Normal file
@@ -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<ScriptResult> {
|
||||||
|
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<PostInput>(postTweet);
|
||||||
80
.claude/skills/x-integration/scripts/quote.ts
Normal file
80
.claude/skills/x-integration/scripts/quote.ts
Normal file
@@ -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<ScriptResult> {
|
||||||
|
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<QuoteInput>(quoteTweet);
|
||||||
74
.claude/skills/x-integration/scripts/reply.ts
Normal file
74
.claude/skills/x-integration/scripts/reply.ts
Normal file
@@ -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<ScriptResult> {
|
||||||
|
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<ReplyInput>(replyToTweet);
|
||||||
62
.claude/skills/x-integration/scripts/retweet.ts
Normal file
62
.claude/skills/x-integration/scripts/retweet.ts
Normal file
@@ -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<ScriptResult> {
|
||||||
|
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<RetweetInput>(retweet);
|
||||||
87
.claude/skills/x-integration/scripts/setup.ts
Normal file
87
.claude/skills/x-integration/scripts/setup.ts
Normal file
@@ -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<void> {
|
||||||
|
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<void>(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);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user