Skip to content

Commit 644d451

Browse files
authored
feat(twitter): add unlike + retweet + unretweet + quote (write-action symmetry P0) (#1400)
Round 21 P0 — Twitter write-action symmetry (4 of 4: unlike, retweet, unretweet, quote). ## Scope Closes write-action gap with existing siblings (`like`, `bookmark`, `unbookmark`, `delete`): - `unlike` (UI strategy, navigateBefore:true) - `retweet` (UI strategy) - `unretweet` (UI strategy) - `quote` (UI strategy, `/compose/post?url=` route — same family as `reply.js` `/compose/post?in_reply_to=`) +745/-0 in initial commit, plus 3 progressive review fixes. Final: 4 adapters + 4 tests; modified `shared.js`, `shared.test.js`, manifest, docs. ## Iteration history (4 heads, 102/102 tests on final) - `07836783` — initial 4 adapters + 4 tests, 96/96 - `55a89776` — fix #1: shared `parseTweetUrl()` URL invariant + quote post-submit verify (102/102) - `dc9eab66` — fix #2: article-scoping for unlike/retweet/unretweet (delete.js sibling pattern) - `8809d2c1` — fix #3: exact status-id matching (`match?.[1] === tweetId`) + quote-card exact id guard ## 4 progressive blockers caught (codex-mini0 lead + F-P-0 aux) 1. **URL validation (silent-clamp class)**: original passed any host containing `/status/<id>`. Fixed: `parseTweetUrl()` requires `https` + Twitter/X exact host + exact `/<user|i>/status/<id>` path; host-suffix, embedded URL, path-suffix all `ArgumentError` pre-nav. 2. **Quote silent-success illusion**: original click-implies-success without composer/toast verify. Fixed: pre-submit quoted-card exact id render assertion + post-submit success toast OR composer-clear assertion, otherwise return failed row. 3. **Broad querySelector scoping (delete.js sibling pattern)**: original state probe + click + post-click verify on conversation pages picked first matching button. Fixed: scope to `article` containing requested exact status id (sibling `clis/twitter/delete.js:22-23` pattern). 4. **Substring vs exact status-id matching**: `/status/123` substring-matched `/status/1234`. Fixed: regex `/\/status\/${id}(?:\/|$)/` segment-edge anchor + `match?.[1] === tweetId` exact compare. ## Cultural sediment (Round 21) **Audit checklist 5 rules (pre-write upstream selection net)**: 1. cross-grep sibling URL-construction patterns before adopting 2. silent-clamp class detection (any normalize-then-trust path) 3. broad querySelector → article-scoping requirement 4. missing-validation early reject before navigation/IO 5. ID-based DOM/URL matching exact-not-substring **Augment framing**: Round 21 audit-first 是 Round 18 字面量 self-check 的 **upstream pre-write 阶段**, 两者作用阶段不同, 共存比替换稳。 **Meta-anchor "Structural exactness for identity matching"** unifying: - URL layer (#1391 isFacebookAuthRedirectPath: `\.php` + `(/|$)` segment edge) - URL parser layer (#1392 parseGrokSessionId: bare UUID exact / URL host-exact-or-subdomain + path-exact) - DOM layer (#1400 article-scoping: status-id `/\/status\/${id}(?:\/|$)/` regex or pathname segment-array exact compare) Common invariant: boundary-lock structural shape, 不 trust substring 模糊 — fuzzy match 是 silent failure 温床。 ## Validation gates (final head `8809d2c1`) Local: Twitter tests 102/102, `node --check` touched files, `npx tsc --noEmit`, `npm run build`, typed-error-lint 189/189, silent-column-drop 103/103, doc-coverage 140/140, docs:build clean, listing-id advisory unchanged 13, `git diff --check` clean, merge-tree clean. GitHub: build×3 (ubuntu/macos/windows) SUCCESS, unit-test×2 shards SUCCESS, bun-test SUCCESS, adapter-test SUCCESS, audit SUCCESS, doc-coverage SUCCESS, docs-build SUCCESS, smoke-test skipped, PR `CLEAN/MERGEABLE`. ## Strategy/UI boundary (better-solution verdict) UI write path acceptable for P0 symmetry (matches existing Twitter write siblings). GraphQL write migration + structured `idempotent:true` flag are cross-sibling upgrades, P5 candidate, not P0 blockers. Round 17 race-mitigation 第 8 连续 race-free execution (this round absorbed author scope-uncertainty hold-then-retract event without producing actual race). Reviewers: - Lead: @codex-mini0 (4-round iteration, all blockers caught) - Aux: @First-principles-0 (better-solution triangulation, scope-discipline verdict, regression invariants) - Author: @opencli-user
1 parent 8d201ae commit 644d451

12 files changed

Lines changed: 929 additions & 1 deletion

File tree

cli-manifest.json

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22760,6 +22760,40 @@
2276022760
"sourceFile": "twitter/profile.js",
2276122761
"navigateBefore": "https://x.com"
2276222762
},
22763+
{
22764+
"site": "twitter",
22765+
"name": "quote",
22766+
"description": "Quote-tweet a specific tweet with your own text",
22767+
"access": "write",
22768+
"domain": "x.com",
22769+
"strategy": "ui",
22770+
"browser": true,
22771+
"args": [
22772+
{
22773+
"name": "url",
22774+
"type": "string",
22775+
"required": true,
22776+
"positional": true,
22777+
"help": "The URL of the tweet to quote"
22778+
},
22779+
{
22780+
"name": "text",
22781+
"type": "string",
22782+
"required": true,
22783+
"positional": true,
22784+
"help": "The text content of your quote"
22785+
}
22786+
],
22787+
"columns": [
22788+
"status",
22789+
"message",
22790+
"text"
22791+
],
22792+
"type": "js",
22793+
"modulePath": "twitter/quote.js",
22794+
"sourceFile": "twitter/quote.js",
22795+
"navigateBefore": true
22796+
},
2276322797
{
2276422798
"site": "twitter",
2276522799
"name": "reply",
@@ -22855,6 +22889,32 @@
2285522889
"sourceFile": "twitter/reply-dm.js",
2285622890
"navigateBefore": true
2285722891
},
22892+
{
22893+
"site": "twitter",
22894+
"name": "retweet",
22895+
"description": "Retweet a specific tweet",
22896+
"access": "write",
22897+
"domain": "x.com",
22898+
"strategy": "ui",
22899+
"browser": true,
22900+
"args": [
22901+
{
22902+
"name": "url",
22903+
"type": "string",
22904+
"required": true,
22905+
"positional": true,
22906+
"help": "The URL of the tweet to retweet"
22907+
}
22908+
],
22909+
"columns": [
22910+
"status",
22911+
"message"
22912+
],
22913+
"type": "js",
22914+
"modulePath": "twitter/retweet.js",
22915+
"sourceFile": "twitter/retweet.js",
22916+
"navigateBefore": true
22917+
},
2285822918
{
2285922919
"site": "twitter",
2286022920
"name": "search",
@@ -23139,6 +23199,58 @@
2313923199
"sourceFile": "twitter/unfollow.js",
2314023200
"navigateBefore": true
2314123201
},
23202+
{
23203+
"site": "twitter",
23204+
"name": "unlike",
23205+
"description": "Remove a like from a specific tweet",
23206+
"access": "write",
23207+
"domain": "x.com",
23208+
"strategy": "ui",
23209+
"browser": true,
23210+
"args": [
23211+
{
23212+
"name": "url",
23213+
"type": "string",
23214+
"required": true,
23215+
"positional": true,
23216+
"help": "The URL of the tweet to unlike"
23217+
}
23218+
],
23219+
"columns": [
23220+
"status",
23221+
"message"
23222+
],
23223+
"type": "js",
23224+
"modulePath": "twitter/unlike.js",
23225+
"sourceFile": "twitter/unlike.js",
23226+
"navigateBefore": true
23227+
},
23228+
{
23229+
"site": "twitter",
23230+
"name": "unretweet",
23231+
"description": "Undo a retweet on a specific tweet",
23232+
"access": "write",
23233+
"domain": "x.com",
23234+
"strategy": "ui",
23235+
"browser": true,
23236+
"args": [
23237+
{
23238+
"name": "url",
23239+
"type": "string",
23240+
"required": true,
23241+
"positional": true,
23242+
"help": "The URL of the tweet to unretweet"
23243+
}
23244+
],
23245+
"columns": [
23246+
"status",
23247+
"message"
23248+
],
23249+
"type": "js",
23250+
"modulePath": "twitter/unretweet.js",
23251+
"sourceFile": "twitter/unretweet.js",
23252+
"navigateBefore": true
23253+
},
2314223254
{
2314323255
"site": "uisdc",
2314423256
"name": "news",

clis/twitter/quote.js

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
import { CommandExecutionError } from '@jackwener/opencli/errors';
2+
import { cli, Strategy } from '@jackwener/opencli/registry';
3+
import { parseTweetUrl } from './shared.js';
4+
5+
function extractTweetId(url) {
6+
return parseTweetUrl(url).id;
7+
}
8+
9+
function buildQuoteComposerUrl(url) {
10+
// Twitter/X quote-tweet compose URL: the `url` param attaches the source
11+
// tweet as a quoted card. Validating tweet-id shape early surfaces obvious
12+
// typos before any browser interaction.
13+
const parsed = parseTweetUrl(url);
14+
return `https://x.com/compose/post?url=${encodeURIComponent(parsed.url)}`;
15+
}
16+
17+
async function submitQuote(page, text, tweetId) {
18+
return page.evaluate(`(async () => {
19+
try {
20+
const visible = (el) => !!el && (el.offsetParent !== null || el.getClientRects().length > 0);
21+
const getStatusId = (href) => {
22+
try {
23+
const match = new URL(href, window.location.origin).pathname.match(/^\\/(?:[^/]+|i)\\/status\\/(\\d+)\\/?$/);
24+
return match?.[1] || null;
25+
} catch {
26+
return null;
27+
}
28+
};
29+
const boxes = Array.from(document.querySelectorAll('[data-testid="tweetTextarea_0"]'));
30+
const box = boxes.find(visible) || boxes[0];
31+
if (!box) {
32+
return { ok: false, message: 'Could not find the quote composer text area. Are you logged in?' };
33+
}
34+
35+
box.focus();
36+
const textToInsert = ${JSON.stringify(text)};
37+
const tweetId = ${JSON.stringify(tweetId)};
38+
// execCommand('insertText') is more reliable with Twitter's Draft.js editor.
39+
if (!document.execCommand('insertText', false, textToInsert)) {
40+
// Fallback to paste event if execCommand fails.
41+
const dataTransfer = new DataTransfer();
42+
dataTransfer.setData('text/plain', textToInsert);
43+
box.dispatchEvent(new ClipboardEvent('paste', {
44+
clipboardData: dataTransfer,
45+
bubbles: true,
46+
cancelable: true,
47+
}));
48+
}
49+
50+
await new Promise(r => setTimeout(r, 1000));
51+
52+
// Confirm the quoted card is rendered before submitting; otherwise we may
53+
// accidentally post a plain tweet without the quote attachment.
54+
let cardAttempts = 0;
55+
let hasQuoteCard = false;
56+
while (cardAttempts < 20) {
57+
hasQuoteCard = Array.from(document.querySelectorAll('a[href*="/status/"]'))
58+
.some((link) => getStatusId(link.href) === tweetId);
59+
if (hasQuoteCard) break;
60+
await new Promise(r => setTimeout(r, 250));
61+
cardAttempts++;
62+
}
63+
if (!hasQuoteCard) {
64+
return { ok: false, message: 'Quote target did not render in the composer. The source tweet may be deleted or restricted.' };
65+
}
66+
67+
const buttons = Array.from(
68+
document.querySelectorAll('[data-testid="tweetButton"], [data-testid="tweetButtonInline"]')
69+
);
70+
const btn = buttons.find((el) => visible(el) && !el.disabled && el.getAttribute('aria-disabled') !== 'true');
71+
if (!btn) {
72+
return { ok: false, message: 'Tweet button is disabled or not found.' };
73+
}
74+
75+
btn.click();
76+
77+
const normalize = s => String(s || '').replace(/\\u00a0/g, ' ').replace(/\\s+/g, ' ').trim();
78+
const expectedText = normalize(textToInsert);
79+
for (let i = 0; i < 30; i++) {
80+
await new Promise(r => setTimeout(r, 500));
81+
const toasts = Array.from(document.querySelectorAll('[role="alert"], [data-testid="toast"]'))
82+
.filter((el) => visible(el));
83+
const successToast = toasts.find((el) => /sent|posted|your post was sent|your tweet was sent/i.test(el.textContent || ''));
84+
if (successToast) return { ok: true, message: 'Quote tweet posted successfully.' };
85+
const alert = toasts.find((el) => /failed|error|try again|not sent|could not/i.test(el.textContent || ''));
86+
if (alert) return { ok: false, message: (alert.textContent || 'Quote tweet failed to post.').trim() };
87+
88+
const visibleBoxes = Array.from(document.querySelectorAll('[data-testid="tweetTextarea_0"]')).filter(visible);
89+
const composerStillHasText = visibleBoxes.some((box) =>
90+
normalize(box.innerText || box.textContent || '').includes(expectedText)
91+
);
92+
if (!composerStillHasText) return { ok: true, message: 'Quote tweet posted successfully.' };
93+
}
94+
return { ok: false, message: 'Quote tweet submission did not complete before timeout.' };
95+
} catch (e) {
96+
return { ok: false, message: e.toString() };
97+
}
98+
})()`);
99+
}
100+
101+
cli({
102+
site: 'twitter',
103+
name: 'quote',
104+
access: 'write',
105+
description: 'Quote-tweet a specific tweet with your own text',
106+
domain: 'x.com',
107+
strategy: Strategy.UI,
108+
browser: true,
109+
args: [
110+
{ name: 'url', type: 'string', required: true, positional: true, help: 'The URL of the tweet to quote' },
111+
{ name: 'text', type: 'string', required: true, positional: true, help: 'The text content of your quote' },
112+
],
113+
columns: ['status', 'message', 'text'],
114+
func: async (page, kwargs) => {
115+
if (!page)
116+
throw new CommandExecutionError('Browser session required for twitter quote');
117+
118+
// Dedicated composer is more reliable than the inline quote-tweet button.
119+
const target = parseTweetUrl(kwargs.url);
120+
await page.goto(`https://x.com/compose/post?url=${encodeURIComponent(target.url)}`, { waitUntil: 'load', settleMs: 2500 });
121+
await page.wait({ selector: '[data-testid="tweetTextarea_0"]', timeout: 15 });
122+
123+
const result = await submitQuote(page, kwargs.text, target.id);
124+
if (result.ok) {
125+
// Wait for network submission to complete
126+
await page.wait(3);
127+
}
128+
return [{
129+
status: result.ok ? 'success' : 'failed',
130+
message: result.message,
131+
text: kwargs.text,
132+
}];
133+
}
134+
});
135+
136+
export const __test__ = {
137+
buildQuoteComposerUrl,
138+
extractTweetId,
139+
};

clis/twitter/quote.test.js

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
import { describe, expect, it } from 'vitest';
2+
import { ArgumentError, CommandExecutionError } from '@jackwener/opencli/errors';
3+
import { getRegistry } from '@jackwener/opencli/registry';
4+
import { __test__ } from './quote.js';
5+
import './quote.js';
6+
import { createPageMock } from '../test-utils.js';
7+
8+
describe('twitter quote helpers', () => {
9+
it('extracts tweet ids from both user and i/status URLs', () => {
10+
expect(__test__.extractTweetId('https://x.com/alice/status/2040254679301718161?s=20')).toBe('2040254679301718161');
11+
expect(__test__.extractTweetId('https://x.com/i/status/2040318731105313143')).toBe('2040318731105313143');
12+
});
13+
14+
it('builds the quote composer URL with the source tweet attached as ?url=...', () => {
15+
const composeUrl = __test__.buildQuoteComposerUrl('https://x.com/alice/status/2040254679301718161?s=20');
16+
// The full source URL is round-tripped via encodeURIComponent — decoding it
17+
// back must yield the original URL. This guards against accidental drops of
18+
// query parameters or fragment characters in future refactors.
19+
const parsed = new URL(composeUrl);
20+
expect(parsed.origin + parsed.pathname).toBe('https://x.com/compose/post');
21+
expect(parsed.searchParams.get('url')).toBe('https://x.com/alice/status/2040254679301718161?s=20');
22+
});
23+
24+
it('rejects malformed URLs before any browser interaction', () => {
25+
expect(() => __test__.buildQuoteComposerUrl('https://x.com/alice/home')).toThrow(/Could not extract tweet ID/);
26+
expect(() => __test__.buildQuoteComposerUrl('not a url')).toThrow(/Invalid tweet URL/);
27+
expect(() => __test__.buildQuoteComposerUrl('https://evil.com/?next=https://x.com/alice/status/2040254679301718161')).toThrow(ArgumentError);
28+
});
29+
});
30+
31+
describe('twitter quote command', () => {
32+
it('navigates to the quote composer and reports success when the script confirms', async () => {
33+
const cmd = getRegistry().get('twitter/quote');
34+
expect(cmd?.func).toBeTypeOf('function');
35+
const page = createPageMock([
36+
{ ok: true, message: 'Quote tweet posted successfully.' },
37+
]);
38+
const result = await cmd.func(page, {
39+
url: 'https://x.com/alice/status/2040254679301718161',
40+
text: 'great take',
41+
});
42+
expect(page.goto).toHaveBeenCalledWith(
43+
'https://x.com/compose/post?url=https%3A%2F%2Fx.com%2Falice%2Fstatus%2F2040254679301718161',
44+
{ waitUntil: 'load', settleMs: 2500 },
45+
);
46+
expect(page.wait).toHaveBeenNthCalledWith(1, { selector: '[data-testid="tweetTextarea_0"]', timeout: 15 });
47+
expect(page.wait).toHaveBeenNthCalledWith(2, 3);
48+
const script = page.evaluate.mock.calls[0][0];
49+
// Quote-attachment guard: the script must verify the quoted card rendered
50+
// before submitting; otherwise we'd silently post a plain tweet without
51+
// the quote attachment.
52+
expect(script).toContain('Quote target did not render');
53+
expect(script).toContain('document.execCommand');
54+
expect(script).toContain('tweetButton');
55+
expect(script).toContain('getStatusId(link.href) === tweetId');
56+
expect(script).toContain('Quote tweet submission did not complete before timeout');
57+
expect(script).toContain('[role="alert"], [data-testid="toast"]');
58+
expect(result).toEqual([
59+
{
60+
status: 'success',
61+
message: 'Quote tweet posted successfully.',
62+
text: 'great take',
63+
},
64+
]);
65+
});
66+
67+
it('returns a failed row when the quote target fails to render', async () => {
68+
const cmd = getRegistry().get('twitter/quote');
69+
expect(cmd?.func).toBeTypeOf('function');
70+
const page = createPageMock([
71+
{ ok: false, message: 'Quote target did not render in the composer. The source tweet may be deleted or restricted.' },
72+
]);
73+
const result = await cmd.func(page, {
74+
url: 'https://x.com/alice/status/2040254679301718161',
75+
text: 'orphaned quote',
76+
});
77+
expect(result).toEqual([
78+
{
79+
status: 'failed',
80+
message: 'Quote target did not render in the composer. The source tweet may be deleted or restricted.',
81+
text: 'orphaned quote',
82+
},
83+
]);
84+
// Only the textarea wait should run when ok is false (no extra 3s post-submit wait).
85+
expect(page.wait).toHaveBeenCalledTimes(1);
86+
});
87+
88+
it('throws CommandExecutionError when no page is provided', async () => {
89+
const cmd = getRegistry().get('twitter/quote');
90+
await expect(cmd.func(undefined, {
91+
url: 'https://x.com/alice/status/2040254679301718161',
92+
text: 'hi',
93+
})).rejects.toThrow(CommandExecutionError);
94+
});
95+
96+
it('rejects invalid tweet URLs before navigation', async () => {
97+
const cmd = getRegistry().get('twitter/quote');
98+
const page = createPageMock([]);
99+
await expect(cmd.func(page, {
100+
url: 'https://x.com.evil.com/alice/status/2040254679301718161',
101+
text: 'hi',
102+
})).rejects.toThrow(ArgumentError);
103+
expect(page.goto).not.toHaveBeenCalled();
104+
expect(page.evaluate).not.toHaveBeenCalled();
105+
});
106+
});

0 commit comments

Comments
 (0)