Add X integration skill (#52)
This commit is contained in:
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