Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion cli-manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -23372,7 +23372,8 @@
"columns": [
"status",
"message",
"text"
"text",
"url"
],
"type": "js",
"modulePath": "twitter/reply.js",
Expand Down
143 changes: 133 additions & 10 deletions clis/twitter/reply.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@ import {
resolveImagePath,
} from './utils.js';

const COMPOSER_SELECTOR = '[data-testid="tweetTextarea_0"]';
const SUBMIT_POLL_MS = 500;
const SUBMIT_TIMEOUT_MS = 15_000;

function buildReplyComposerUrl(rawUrl) {
// Replaces the legacy local extractTweetId which used `/\/status\/(\d+)/`
// (silent: matched `/status/1234567` on substring `/status/123` and
Expand All @@ -19,7 +23,36 @@ function buildReplyComposerUrl(rawUrl) {
return `https://x.com/compose/post?in_reply_to=${target.id}`;
}

async function submitReply(page, text) {
function isPromiseCollectedError(err) {
const msg = err instanceof Error ? err.message : String(err);
return msg.includes('Promise was collected');
}

async function openReplyComposer(page, rawUrl) {
await page.goto(buildReplyComposerUrl(rawUrl), { waitUntil: 'load', settleMs: 2500 });
try {
await page.wait({ selector: COMPOSER_SELECTOR, timeout: 15 });
return { ok: true };
} catch {
// X sometimes leaves /compose/post?in_reply_to=<id> on the Home
// timeline behind a loading dialog. Fall back to the canonical tweet
// page and click the visible Reply action there.
await page.goto(rawUrl, { waitUntil: 'load', settleMs: 2500 });
const clicked = await page.evaluate(`(() => {
const visible = (el) => !!el && (el.offsetParent !== null || el.getClientRects().length > 0);
const buttons = Array.from(document.querySelectorAll('[data-testid="reply"]'));
const btn = buttons.find((el) => visible(el) && !el.disabled && el.getAttribute('aria-disabled') !== 'true');
if (!btn) return { ok: false, message: 'Could not find the reply button on the target tweet.' };
btn.click();
return { ok: true };
})()`);
if (!clicked?.ok) return clicked;
await page.wait({ selector: COMPOSER_SELECTOR, timeout: 15 });
return { ok: true };
}
}

async function insertReplyText(page, text) {
return page.evaluate(`(async () => {
try {
const visible = (el) => !!el && (el.offsetParent !== null || el.getClientRects().length > 0);
Expand All @@ -44,23 +77,109 @@ async function submitReply(page, text) {
}

await new Promise(r => setTimeout(r, 1000));
const normalize = s => String(s || '').replace(/\\u00a0/g, ' ').replace(/\\s+/g, ' ').trim();
const actual = box.innerText || box.textContent || '';
if (!normalize(actual).includes(normalize(textToInsert))) {
return { ok: false, message: 'Could not verify reply text in the composer after typing.', actualText: actual };
}
return { ok: true };
} catch (e) {
return { ok: false, message: e.toString() };
}
})()`);
}

async function clickReplyButton(page) {
return page.evaluate(`(() => {
try {
const visible = (el) => !!el && (el.offsetParent !== null || el.getClientRects().length > 0);
const buttons = Array.from(
document.querySelectorAll('[data-testid="tweetButton"], [data-testid="tweetButtonInline"]')
);
const btn = buttons.find((el) => visible(el) && !el.disabled);
const btn = buttons.find((el) => visible(el) && !el.disabled && el.getAttribute('aria-disabled') !== 'true');
if (!btn) {
return { ok: false, message: 'Reply button is disabled or not found.' };
}

btn.click();
return { ok: true, message: 'Reply posted successfully.' };
return { ok: true };
} catch (e) {
return { ok: false, message: e.toString() };
}
})()`);
}

async function detectReplySent(page) {
return page.evaluate(`(() => {
const visible = (el) => !!el && (el.offsetParent !== null || el.getClientRects().length > 0);
const toasts = Array.from(document.querySelectorAll('[role="alert"], [data-testid="toast"]'))
.filter((el) => visible(el));
const successToast = toasts.find((el) => /sent|posted|your post was sent|your tweet was sent/i.test(el.textContent || ''));
if (!successToast) return { ok: false };
const link = successToast.querySelector('a[href*="/status/"]');
return {
ok: true,
message: 'Reply posted successfully.',
url: link?.href || link?.getAttribute('href') || undefined
};
})()`);
}

async function waitForReplySent(page, text) {
const iterations = Math.ceil(SUBMIT_TIMEOUT_MS / SUBMIT_POLL_MS);
try {
return await page.evaluate(`(async () => {
const expected = ${JSON.stringify(text)};
const normalize = s => String(s || '').replace(/\\u00a0/g, ' ').replace(/\\s+/g, ' ').trim();
const expectedText = normalize(expected);
const visible = (el) => !!el && (el.offsetParent !== null || el.getClientRects().length > 0);
for (let i = 0; i < ${JSON.stringify(iterations)}; i++) {
await new Promise(r => setTimeout(r, ${JSON.stringify(SUBMIT_POLL_MS)}));
const toasts = Array.from(document.querySelectorAll('[role="alert"], [data-testid="toast"]'))
.filter((el) => visible(el));
const successToast = toasts.find((el) => /sent|posted|your post was sent|your tweet was sent/i.test(el.textContent || ''));
if (successToast) {
const link = successToast.querySelector('a[href*="/status/"]');
return {
ok: true,
message: 'Reply posted successfully.',
url: link?.href || link?.getAttribute('href') || undefined
};
}
const alert = toasts.find((el) => /failed|error|try again|not sent|could not/i.test(el.textContent || ''));
if (alert) return { ok: false, message: (alert.textContent || 'Reply failed to post.').trim() };

const boxes = Array.from(document.querySelectorAll('[data-testid="tweetTextarea_0"]')).filter(visible);
const composerStillHasText = boxes.some((box) => normalize(box.innerText || box.textContent || '').includes(expectedText));
if (!composerStillHasText) return { ok: true, message: 'Reply posted successfully.' };
}
return { ok: false, message: 'Reply submission did not complete before timeout.' };
})()`);
} catch (err) {
// X may route the SPA immediately after click, making CDP collect the
// polling promise even though the reply was submitted. If the page now
// shows the success toast, report success instead of a false negative.
if (!isPromiseCollectedError(err)) throw err;
await page.wait(2);
const recovered = await detectReplySent(page);
if (recovered?.ok) return recovered;
throw err;
}
}

async function submitReply(page, text) {
const typed = await insertReplyText(page, text);
if (!typed?.ok) return typed;
let clicked;
try {
clicked = await clickReplyButton(page);
} catch (err) {
if (!isPromiseCollectedError(err)) throw err;
}
if (clicked && !clicked.ok) return clicked;
return waitForReplySent(page, text);
}

cli({
site: 'twitter',
name: 'reply',
Expand All @@ -75,7 +194,7 @@ cli({
{ name: 'image', help: 'Optional local image path to attach to the reply' },
{ name: 'image-url', help: 'Optional remote image URL to download and attach to the reply' },
],
columns: ['status', 'message', 'text'],
columns: ['status', 'message', 'text', 'url'],
func: async (page, kwargs) => {
if (!page)
throw new CommandExecutionError('Browser session required for twitter reply');
Expand All @@ -92,21 +211,24 @@ cli({
localImagePath = downloaded.absPath;
cleanupDir = downloaded.cleanupDir;
}
// Dedicated composer is more reliable than the inline tweet page reply box.
await page.goto(buildReplyComposerUrl(kwargs.url), { waitUntil: 'load', settleMs: 2500 });
await page.wait({ selector: '[data-testid="tweetTextarea_0"]', timeout: 15 });
// Dedicated composer is normally more reliable than the inline
// tweet page reply box, but X occasionally leaves that route on the
// Home timeline behind a loading dialog. openReplyComposer falls
// back to the target tweet's visible Reply action.
const composer = await openReplyComposer(page, kwargs.url);
if (!composer?.ok) {
return [{ status: 'failed', message: composer?.message ?? 'Could not open the reply composer.', text: kwargs.text }];
}
if (localImagePath) {
await page.wait({ selector: COMPOSER_FILE_INPUT_SELECTOR, timeout: 20 });
await attachComposerImage(page, localImagePath);
}
const result = await submitReply(page, kwargs.text);
if (result.ok) {
await page.wait(3); // Wait for network submission to complete
}
return [{
status: result.ok ? 'success' : 'failed',
message: result.message,
text: kwargs.text,
...(result.url ? { url: result.url } : {}),
...(kwargs.image ? { image: kwargs.image } : {}),
...(kwargs['image-url'] ? { 'image-url': kwargs['image-url'] } : {}),
}];
Expand All @@ -119,4 +241,5 @@ cli({
});
export const __test__ = {
buildReplyComposerUrl,
isPromiseCollectedError,
};
55 changes: 55 additions & 0 deletions clis/twitter/reply.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ describe('twitter reply command', () => {
const cmd = getRegistry().get('twitter/reply');
expect(cmd?.func).toBeTypeOf('function');
const page = createPageMock([
{ ok: true },
{ ok: true },
{ ok: true, message: 'Reply posted successfully.' },
]);
const result = await cmd.func(page, {
Expand All @@ -38,6 +40,8 @@ describe('twitter reply command', () => {
const setFileInput = vi.fn().mockResolvedValue(undefined);
const page = createPageMock([
{ ok: true, previewCount: 1 },
{ ok: true },
{ ok: true },
{ ok: true, message: 'Reply posted successfully.' },
], {
setFileInput,
Expand Down Expand Up @@ -74,6 +78,8 @@ describe('twitter reply command', () => {
const setFileInput = vi.fn().mockResolvedValue(undefined);
const page = createPageMock([
{ ok: true, previewCount: 1 },
{ ok: true },
{ ok: true },
{ ok: true, message: 'Reply posted successfully.' },
], {
setFileInput,
Expand Down Expand Up @@ -102,6 +108,55 @@ describe('twitter reply command', () => {
]);
vi.unstubAllGlobals();
});
it('falls back to the target tweet page when the dedicated composer route does not expose a textarea', async () => {
const cmd = getRegistry().get('twitter/reply');
expect(cmd?.func).toBeTypeOf('function');
const wait = vi.fn()
.mockRejectedValueOnce(new Error('Selector not found: [data-testid="tweetTextarea_0"]'))
.mockResolvedValue(undefined);
const page = createPageMock([
{ ok: true }, // click target tweet page Reply button
{ ok: true }, // insert reply text
{ ok: true }, // click composer Reply button
{ ok: true, message: 'Reply posted successfully.' }, // submit completed
], { wait });

const url = 'https://x.com/_kop6/status/2040254679301718161?s=20';
const result = await cmd.func(page, { url, text: 'fallback reply' });

expect(page.goto).toHaveBeenNthCalledWith(1, 'https://x.com/compose/post?in_reply_to=2040254679301718161', { waitUntil: 'load', settleMs: 2500 });
expect(page.goto).toHaveBeenNthCalledWith(2, url, { waitUntil: 'load', settleMs: 2500 });
expect(page.evaluate.mock.calls[0][0]).toContain('[data-testid="reply"]');
expect(wait).toHaveBeenLastCalledWith({ selector: '[data-testid="tweetTextarea_0"]', timeout: 15 });
expect(result).toEqual([{ status: 'success', message: 'Reply posted successfully.', text: 'fallback reply' }]);
});
it('treats an X success toast as success after a Promise was collected error', async () => {
const cmd = getRegistry().get('twitter/reply');
expect(cmd?.func).toBeTypeOf('function');
const evaluate = vi.fn()
.mockResolvedValueOnce({ ok: true }) // insert reply text
.mockResolvedValueOnce({ ok: true }) // click Reply
.mockRejectedValueOnce(new Error('{"code":-32000,"message":"Promise was collected"}'))
.mockResolvedValueOnce({
ok: true,
message: 'Reply posted successfully.',
url: 'https://x.com/me/status/123',
});
const page = createPageMock([], { evaluate });

const result = await cmd.func(page, {
url: 'https://x.com/_kop6/status/2040254679301718161?s=20',
text: 'toast recovery',
});

expect(page.wait).toHaveBeenCalledWith(2);
expect(result).toEqual([{
status: 'success',
message: 'Reply posted successfully.',
text: 'toast recovery',
url: 'https://x.com/me/status/123',
}]);
});
it('rejects using --image and --image-url together', async () => {
const cmd = getRegistry().get('twitter/reply');
expect(cmd?.func).toBeTypeOf('function');
Expand Down
Loading