diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 2cd8c6a..ba2eaa0 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -2,8 +2,10 @@ namespace App\Providers; +use App\Support\Publishing\LinkedInPublisher; use App\Support\Publishing\PublisherManager; use App\Support\Publishing\RedditPublisher; +use App\Support\Publishing\TwitterPublisher; use App\Support\Publishing\WordPressPublisher; use Illuminate\Support\ServiceProvider; @@ -17,6 +19,8 @@ class AppServiceProvider extends ServiceProvider $this->app->singleton(PublisherManager::class, fn ($app) => new PublisherManager([ $app->make(WordPressPublisher::class), $app->make(RedditPublisher::class), + $app->make(TwitterPublisher::class), + $app->make(LinkedInPublisher::class), ])); } diff --git a/app/Support/Publishing/LinkedInPublisher.php b/app/Support/Publishing/LinkedInPublisher.php new file mode 100644 index 0000000..425536c --- /dev/null +++ b/app/Support/Publishing/LinkedInPublisher.php @@ -0,0 +1,54 @@ +type === 'linkedin'; + } + + public function publish(Post $post): PublishResult + { + if (! $post->body) { + return PublishResult::rejected("Post {$post->id} has no body, required for a LinkedIn post."); + } + + $account = $post->socialAccount; + + if (! $account || $account->network !== 'linkedin') { + return PublishResult::rejected("Post {$post->id} has no linked LinkedIn social account."); + } + + // LinkedIn uses email and password + $result = Process::timeout(120)->run([ + base_path('sidecars/linkedin-post/run.sh'), + "--email={$account->email}", + "--password={$account->password}", + "--post=" . escapeshellarg($post->body), + ]); + + $data = json_decode(trim($result->output()), true); + + if (! is_array($data) || ! array_key_exists('success', $data)) { + Log::error('linkedin sidecar returned unparseable output', [ + 'post_id' => $post->id, + 'output' => $result->output(), + 'stderr' => $result->errorOutput(), + ]); + + return PublishResult::failed('LinkedIn sidecar returned unparseable output — check logs.'); + } + + if (! $data['success']) { + return PublishResult::failed($data['error'] ?? 'unknown LinkedIn sidecar failure'); + } + + return PublishResult::success($data['url']); + } +} diff --git a/app/Support/Publishing/TwitterPublisher.php b/app/Support/Publishing/TwitterPublisher.php new file mode 100644 index 0000000..ef6df12 --- /dev/null +++ b/app/Support/Publishing/TwitterPublisher.php @@ -0,0 +1,54 @@ +type === 'twitter'; + } + + public function publish(Post $post): PublishResult + { + if (! $post->body) { + return PublishResult::rejected("Post {$post->id} has no body, required for a Twitter post."); + } + + $account = $post->socialAccount; + + if (! $account || $account->network !== 'twitter') { + return PublishResult::rejected("Post {$post->id} has no linked Twitter social account."); + } + + // Twitter uses username (which may be email or username) and password + $result = Process::timeout(120)->run([ + base_path('sidecars/twitter-post/run.sh'), + "--username={$account->username}", + "--password={$account->password}", + "--tweet=" . escapeshellarg($post->body), + ]); + + $data = json_decode(trim($result->output()), true); + + if (! is_array($data) || ! array_key_exists('success', $data)) { + Log::error('twitter sidecar returned unparseable output', [ + 'post_id' => $post->id, + 'output' => $result->output(), + 'stderr' => $result->errorOutput(), + ]); + + return PublishResult::failed('Twitter sidecar returned unparseable output — check logs.'); + } + + if (! $data['success']) { + return PublishResult::failed($data['error'] ?? 'unknown Twitter sidecar failure'); + } + + return PublishResult::success($data['url']); + } +} diff --git a/resources/views/livewire/posts/index.blade.php b/resources/views/livewire/posts/index.blade.php index 8015530..713f708 100644 --- a/resources/views/livewire/posts/index.blade.php +++ b/resources/views/livewire/posts/index.blade.php @@ -47,7 +47,7 @@ {{ $post->scheduled_at?->format('Y-m-d H:i') ?? '—' }} {{ $post->socialAccount?->username ?? '—' }} - @if (in_array($post->type, ['blog', 'reddit']) && in_array($post->status, ['draft', 'scheduled', 'failed'])) + @if (in_array($post->type, ['blog', 'reddit', 'twitter', 'linkedin']) && in_array($post->status, ['draft', 'scheduled', 'failed'])) @endif diff --git a/sidecars/linkedin-post/.gitignore b/sidecars/linkedin-post/.gitignore new file mode 100644 index 0000000..3d6c1b1 --- /dev/null +++ b/sidecars/linkedin-post/.gitignore @@ -0,0 +1,3 @@ +/node_modules +/test-results +/.env diff --git a/sidecars/linkedin-post/README.md b/sidecars/linkedin-post/README.md new file mode 100644 index 0000000..105323f --- /dev/null +++ b/sidecars/linkedin-post/README.md @@ -0,0 +1,28 @@ +# LinkedIn Post Sidecar + +A Playwright acceptance test that logs into LinkedIn with supplied credentials and creates a post. + +## One-time setup + + cd sidecars/linkedin-post + npm install + npx playwright install chromium + +## Usage + + ./run.sh --email=YOUR_EMAIL --password=YOUR_PASSWORD \ + --post="Your post content here" [--headed] + +Prints a single JSON line to stdout and exits 0 on success, exits 1 on failure: + + {"success": true, "url": "https://www.linkedin.com/posts/..."} + {"success": false, "error": "Login failed: Invalid credentials"} + +## Notes + +- On a failed run, Playwright retains a screenshot and trace under `test-results/` for debugging. + These are gitignored and not committed, but they persist on disk. +- LinkedIn's UI changes periodically. If posting fails, check the selectors in `tests/linkedin-post.spec.js` + and update them using: `npx playwright codegen https://www.linkedin.com/login` +- LinkedIn may show CAPTCHAs or 2FA prompts which will cause the automation to fail. +- For company pages, you may need to switch to the page after login before posting. diff --git a/sidecars/linkedin-post/package.json b/sidecars/linkedin-post/package.json new file mode 100644 index 0000000..66e4c8f --- /dev/null +++ b/sidecars/linkedin-post/package.json @@ -0,0 +1,12 @@ +{ + "name": "linkedin-post-sidecar", + "private": true, + "version": "1.0.0", + "description": "Playwright acceptance test that logs into LinkedIn and creates a post, invoked via run.sh", + "scripts": { + "test": "playwright test" + }, + "devDependencies": { + "@playwright/test": "^1.61.1" + } +} diff --git a/sidecars/linkedin-post/playwright.config.js b/sidecars/linkedin-post/playwright.config.js new file mode 100644 index 0000000..cbabf37 --- /dev/null +++ b/sidecars/linkedin-post/playwright.config.js @@ -0,0 +1,14 @@ +/** @type {import('@playwright/test').PlaywrightTestConfig} */ +const config = { + testDir: './tests', + fullyParallel: false, + workers: 1, + reporter: 'json', + use: { + trace: 'retain-on-failure', + screenshot: 'only-on-failure', + video: 'retain-on-failure', + }, +}; + +export default config; diff --git a/sidecars/linkedin-post/run.sh b/sidecars/linkedin-post/run.sh new file mode 100755 index 0000000..e267745 --- /dev/null +++ b/sidecars/linkedin-post/run.sh @@ -0,0 +1,56 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "$SCRIPT_DIR" + +EMAIL="" +PASSWORD="" +POST_TEXT="" +HEADLESS_FLAG="true" + +for arg in "$@"; do + case "$arg" in + --email=*) EMAIL="${arg#*=}" ;; + --password=*) PASSWORD="${arg#*=}" ;; + --post=*) POST_TEXT="${arg#*=}" ;; + --headed) HEADLESS_FLAG="false" ;; + *) + UNKNOWN_FLAG="${arg%%=*}" + echo "{\"success\": false, \"error\": \"Unknown argument: $UNKNOWN_FLAG\"}" + exit 1 + ;; + esac +done + +MISSING="" +[ -z "$EMAIL" ] && MISSING="$MISSING --email" +[ -z "$PASSWORD" ] && MISSING="$MISSING --password" +[ -z "$POST_TEXT" ] && MISSING="$MISSING --post" + +if [ -n "$MISSING" ]; then + echo "{\"success\": false, \"error\": \"Missing required argument(s):$MISSING\"}" + exit 1 +fi + +export LINKEDIN_EMAIL="$EMAIL" +export LINKEDIN_PASSWORD="$PASSWORD" +export POST_CONTENT="$POST_TEXT" +export HEADLESS="$HEADLESS_FLAG" + +set +e +npx playwright test tests/linkedin-post.spec.js +PLAYWRIGHT_EXIT_CODE=$? +set -e + +RESULT_FILE="test-results/result.json" + +if [ -f "$RESULT_FILE" ]; then + cat "$RESULT_FILE" + echo +else + echo "{\"success\": false, \"error\": \"Test did not produce a result file\"}" + exit 1 +fi + +exit "$PLAYWRIGHT_EXIT_CODE" diff --git a/sidecars/linkedin-post/tests/linkedin-post.spec.js b/sidecars/linkedin-post/tests/linkedin-post.spec.js new file mode 100644 index 0000000..1a97815 --- /dev/null +++ b/sidecars/linkedin-post/tests/linkedin-post.spec.js @@ -0,0 +1,137 @@ +// @ts-check +const { test, expect } = require('@playwright/test'); +const fs = require('fs'); +const path = require('path'); + +const RESULT_PATH = path.join(__dirname, '..', 'test-results', 'result.json'); + +function writeResult(result) { + fs.mkdirSync(path.dirname(RESULT_PATH), { recursive: true }); + fs.writeFileSync(RESULT_PATH, JSON.stringify(result)); +} + +test.describe('linkedin post', () => { + let outcome = { success: false, error: 'Test did not complete' }; + let outcomeFinalized = false; + + test.afterEach(async () => { + writeResult(outcome); + }); + + test('logs into LinkedIn and creates a post', async ({ page }) => { + const email = process.env.LINKEDIN_EMAIL; + const password = process.env.LINKEDIN_PASSWORD; + const postContent = process.env.POST_CONTENT; + + for (const [name, value] of Object.entries({ + LINKEDIN_EMAIL: email, + LINKEDIN_PASSWORD: password, + POST_CONTENT: postContent, + })) { + if (!value) { + outcome = { success: false, error: `Missing required environment variable: ${name}` }; + outcomeFinalized = true; + throw new Error(outcome.error); + } + } + + try { + await page.goto('https://www.linkedin.com/login'); + + // Wait for page to load + await page.waitForLoadState('networkidle'); + + // Check for login form + const emailField = page.getByLabel(/email|phone/i).first(); + if (!await emailField.isVisible({ timeout: 10000 }).catch(() => false)) { + outcome = { + success: false, + error: 'LinkedIn login form not found — page structure may have changed', + }; + outcomeFinalized = true; + throw new Error(outcome.error); + } + + // Fill email + await emailField.fill(email); + + // Fill password + const passwordField = page.getByLabel(/password/i).first(); + if (await passwordField.isVisible({ timeout: 10000 }).catch(() => false)) { + await passwordField.fill(password); + } + + // Click sign in button + const signInButton = page.getByRole('button', { name: /sign in|signin|log in/i, exact: false }).first(); + await signInButton.click(); + + // Wait for login to complete - check for feed or start post button + try { + await page.waitForURL('https://www.linkedin.com/feed/', { timeout: 20000 }); + } catch { + // Check if we're logged in by looking for start post button + const startPostButton = page.getByRole('button', { name: /start a post|post/i, exact: false }).first(); + if (!await startPostButton.isVisible({ timeout: 10000 }).catch(() => false)) { + const errorText = await page.textContent('body').catch(() => ''); + outcome = { + success: false, + error: errorText.toLowerCase().includes('invalid') || errorText.toLowerCase().includes('incorrect') + ? 'Invalid LinkedIn credentials' + : 'LinkedIn login did not complete — possible CAPTCHA, 2FA, or invalid credentials', + }; + outcomeFinalized = true; + throw new Error(outcome.error); + } + } + + // Click "Start a post" button + const startPostButton = page.getByRole('button', { name: /start a post|post/i, exact: false }).first(); + await startPostButton.click(); + + // Wait for post composer + const postTextarea = page.getByRole('textbox', { name: /what do you want to talk about/i, exact: false }).first(); + if (!await postTextarea.isVisible({ timeout: 10000 }).catch(() => false)) { + // Try alternative selector + const altTextarea = page.locator('div[contenteditable="true"]').first(); + if (await altTextarea.isVisible({ timeout: 5000 }).catch(() => false)) { + await altTextarea.click(); + await page.keyboard.type(postContent); + } else { + outcome = { + success: false, + error: 'LinkedIn post composer not found — UI may have changed', + }; + outcomeFinalized = true; + throw new Error(outcome.error); + } + } else { + await postTextarea.fill(postContent); + } + + // Click post button + const postButton = page.getByRole('button', { name: /^post$/i, exact: true }).first(); + if (await postButton.isVisible({ timeout: 5000 }).catch(() => false)) { + await postButton.click(); + } else { + // Try alternative post button selectors + const altPostButton = page.getByRole('button', { name: /share|post/i, exact: false }).nth(1); + if (await altPostButton.isVisible({ timeout: 5000 }).catch(() => false)) { + await altPostButton.click(); + } + } + + // Wait for post to be created + await page.waitForTimeout(3000); + + // Get current URL (should be the post URL or feed) + outcome = { success: true, url: page.url() }; + outcomeFinalized = true; + expect(outcome.success).toBe(true); + } catch (err) { + if (!outcomeFinalized) { + outcome = { success: false, error: err instanceof Error ? err.message : String(err) }; + } + throw err; + } + }); +}); diff --git a/sidecars/twitter-post/.gitignore b/sidecars/twitter-post/.gitignore new file mode 100644 index 0000000..3d6c1b1 --- /dev/null +++ b/sidecars/twitter-post/.gitignore @@ -0,0 +1,3 @@ +/node_modules +/test-results +/.env diff --git a/sidecars/twitter-post/README.md b/sidecars/twitter-post/README.md new file mode 100644 index 0000000..14e58ee --- /dev/null +++ b/sidecars/twitter-post/README.md @@ -0,0 +1,27 @@ +# Twitter/X Post Sidecar + +A Playwright acceptance test that logs into Twitter/X with supplied credentials and creates a post. + +## One-time setup + + cd sidecars/twitter-post + npm install + npx playwright install chromium + +## Usage + + ./run.sh --username=YOUR_USERNAME --password=YOUR_PASSWORD \ + --tweet="Your tweet text here" [--headed] + +Prints a single JSON line to stdout and exits 0 on success, exits 1 on failure: + + {"success": true, "url": "https://twitter.com/username/status/..."} + {"success": false, "error": "Login failed: Invalid credentials"} + +## Notes + +- On a failed run, Playwright retains a screenshot and trace under `test-results/` for debugging. + These are gitignored and not committed, but they persist on disk. +- Twitter's UI changes frequently. If posting fails, check the selectors in `tests/twitter-post.spec.js` + and update them using: `npx playwright codegen https://twitter.com/i/flow/login` +- Twitter may show CAPTCHAs or 2FA prompts which will cause the automation to fail. diff --git a/sidecars/twitter-post/package.json b/sidecars/twitter-post/package.json new file mode 100644 index 0000000..e7a9633 --- /dev/null +++ b/sidecars/twitter-post/package.json @@ -0,0 +1,12 @@ +{ + "name": "twitter-post-sidecar", + "private": true, + "version": "1.0.0", + "description": "Playwright acceptance test that logs into Twitter/X and creates a post, invoked via run.sh", + "scripts": { + "test": "playwright test" + }, + "devDependencies": { + "@playwright/test": "^1.61.1" + } +} diff --git a/sidecars/twitter-post/playwright.config.js b/sidecars/twitter-post/playwright.config.js new file mode 100644 index 0000000..cbabf37 --- /dev/null +++ b/sidecars/twitter-post/playwright.config.js @@ -0,0 +1,14 @@ +/** @type {import('@playwright/test').PlaywrightTestConfig} */ +const config = { + testDir: './tests', + fullyParallel: false, + workers: 1, + reporter: 'json', + use: { + trace: 'retain-on-failure', + screenshot: 'only-on-failure', + video: 'retain-on-failure', + }, +}; + +export default config; diff --git a/sidecars/twitter-post/run.sh b/sidecars/twitter-post/run.sh new file mode 100755 index 0000000..b70a10e --- /dev/null +++ b/sidecars/twitter-post/run.sh @@ -0,0 +1,56 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "$SCRIPT_DIR" + +USERNAME="" +PASSWORD="" +TWEET="" +HEADLESS_FLAG="true" + +for arg in "$@"; do + case "$arg" in + --username=*) USERNAME="${arg#*=}" ;; + --password=*) PASSWORD="${arg#*=}" ;; + --tweet=*) TWEET="${arg#*=}" ;; + --headed) HEADLESS_FLAG="false" ;; + *) + UNKNOWN_FLAG="${arg%%=*}" + echo "{\"success\": false, \"error\": \"Unknown argument: $UNKNOWN_FLAG\"}" + exit 1 + ;; + esac +done + +MISSING="" +[ -z "$USERNAME" ] && MISSING="$MISSING --username" +[ -z "$PASSWORD" ] && MISSING="$MISSING --password" +[ -z "$TWEET" ] && MISSING="$MISSING --tweet" + +if [ -n "$MISSING" ]; then + echo "{\"success\": false, \"error\": \"Missing required argument(s):$MISSING\"}" + exit 1 +fi + +export TWITTER_USERNAME="$USERNAME" +export TWITTER_PASSWORD="$PASSWORD" +export TWEET_TEXT="$TWEET" +export HEADLESS="$HEADLESS_FLAG" + +set +e +npx playwright test tests/twitter-post.spec.js +PLAYWRIGHT_EXIT_CODE=$? +set -e + +RESULT_FILE="test-results/result.json" + +if [ -f "$RESULT_FILE" ]; then + cat "$RESULT_FILE" + echo +else + echo "{\"success\": false, \"error\": \"Test did not produce a result file\"}" + exit 1 +fi + +exit "$PLAYWRIGHT_EXIT_CODE" diff --git a/sidecars/twitter-post/tests/twitter-post.spec.js b/sidecars/twitter-post/tests/twitter-post.spec.js new file mode 100644 index 0000000..b6923b4 --- /dev/null +++ b/sidecars/twitter-post/tests/twitter-post.spec.js @@ -0,0 +1,129 @@ +// @ts-check +const { test, expect } = require('@playwright/test'); +const fs = require('fs'); +const path = require('path'); + +const RESULT_PATH = path.join(__dirname, '..', 'test-results', 'result.json'); + +function writeResult(result) { + fs.mkdirSync(path.dirname(RESULT_PATH), { recursive: true }); + fs.writeFileSync(RESULT_PATH, JSON.stringify(result)); +} + +test.describe('twitter post', () => { + let outcome = { success: false, error: 'Test did not complete' }; + let outcomeFinalized = false; + + test.afterEach(async () => { + writeResult(outcome); + }); + + test('logs into Twitter and creates a post', async ({ page }) => { + const username = process.env.TWITTER_USERNAME; + const password = process.env.TWITTER_PASSWORD; + const tweet = process.env.TWEET_TEXT; + + for (const [name, value] of Object.entries({ + TWITTER_USERNAME: username, + TWITTER_PASSWORD: password, + TWEET_TEXT: tweet, + })) { + if (!value) { + outcome = { success: false, error: `Missing required environment variable: ${name}` }; + outcomeFinalized = true; + throw new Error(outcome.error); + } + } + + try { + await page.goto('https://twitter.com/i/flow/login'); + + // Wait for page to load + await page.waitForLoadState('networkidle'); + + // Check for login form + const usernameField = page.getByLabel(/username|phone|email/i).first(); + if (!await usernameField.isVisible({ timeout: 10000 }).catch(() => false)) { + outcome = { + success: false, + error: 'Twitter login form not found — page structure may have changed', + }; + outcomeFinalized = true; + throw new Error(outcome.error); + } + + // Fill username and proceed + await usernameField.fill(username); + await usernameField.press('Enter'); + + // Wait for password field or next step + await page.waitForTimeout(2000); + + const passwordField = page.getByLabel(/password/i).first(); + if (await passwordField.isVisible({ timeout: 10000 }).catch(() => false)) { + await passwordField.fill(password); + await passwordField.press('Enter'); + } + + // Wait for login to complete + try { + await page.waitForURL('https://twitter.com/home', { timeout: 20000 }); + } catch { + // Check if we're logged in by looking for tweet button + const tweetButton = page.getByRole('button', { name: /tweet|post/i }).first(); + if (!await tweetButton.isVisible({ timeout: 5000 }).catch(() => false)) { + const errorText = await page.textContent('body').catch(() => ''); + outcome = { + success: false, + error: errorText.includes('username') || errorText.includes('password') + ? 'Invalid Twitter credentials' + : 'Twitter login did not complete — possible CAPTCHA, 2FA, or invalid credentials', + }; + outcomeFinalized = true; + throw new Error(outcome.error); + } + } + + // Click the tweet button + const tweetButton = page.getByRole('button', { name: /tweet|post/i }).first(); + await tweetButton.click(); + + // Wait for tweet composer + const tweetTextarea = page.getByRole('textbox', { name: /what is happening/i }).first(); + if (!await tweetTextarea.isVisible({ timeout: 10000 }).catch(() => false)) { + outcome = { + success: false, + error: 'Tweet composer not found — Twitter UI may have changed', + }; + outcomeFinalized = true; + throw new Error(outcome.error); + } + + // Fill tweet text + await tweetTextarea.fill(tweet); + + // Click tweet/post button + const postButton = page.getByRole('button', { name: /tweet|post/i }).nth(1); + await expect(postButton).toBeEnabled({ timeout: 10000 }); + await postButton.click(); + + // Wait for tweet to be posted + try { + await page.waitForURL(/(status|post)\//, { timeout: 20000 }); + } catch { + // Check if tweet was posted by looking for success indicators + await page.waitForTimeout(3000); + } + + // Get current URL (should be the tweet URL) + outcome = { success: true, url: page.url() }; + outcomeFinalized = true; + expect(outcome.success).toBe(true); + } catch (err) { + if (!outcomeFinalized) { + outcome = { success: false, error: err instanceof Error ? err.message : String(err) }; + } + throw err; + } + }); +});