Skip to content

feat(twitter): add unlike + retweet + unretweet + quote (write-action symmetry P0)#1400

Merged
jackwener merged 4 commits intomainfrom
twitter-write-symmetry-p0
May 7, 2026
Merged

feat(twitter): add unlike + retweet + unretweet + quote (write-action symmetry P0)#1400
jackwener merged 4 commits intomainfrom
twitter-write-symmetry-p0

Conversation

@jackwener
Copy link
Copy Markdown
Owner

Summary

Mirror twitter-cli write-action coverage. Existing twitter adapters had like / follow+unfollow / bookmark+unbookmark / block+unblock pairs but no unlike, no retweet/unretweet, and no quote-tweet. This PR fills the four gaps. P0 of a 5-PR series sketched after WAWQAQ asked me to compare the public twitter-cli and identify gaps.

What's added

Command Verb Verify branch
unlike <url> click [data-testid="unlike"] [data-testid="like"] reappears
retweet <url> click retweet → wait retweetConfirm → click [data-testid="unretweet"] appears
unretweet <url> click unretweet → wait unretweetConfirm → click [data-testid="retweet"] reappears
quote <url> <text> nav /compose/post?url=<tweet> → type → assert quoted-card rendered → submit success toast / composer cleared

All four are idempotent (calling on an already-done state returns status=success with an "already" message).

Tests

  • 4 new test files, 15 contract assertions covering: ok-path UI flow, idempotent already-done branch, fail-path no-rewait, CommandExecutionError on missing page, and (for quote) malformed-URL up-front rejection + encodeURIComponent round-trip on the ?url= attachment.
  • Full clis/twitter test suite: 18 files / 96 tests pass.

Audits / hygiene

  • typed-error-lint: 189 → 189 (0 new)
  • silent-column-drop: 103 → 103 (0 new)
  • doc coverage: 140/140
  • docs/adapters/browser/twitter.md updated with the 4 commands + a write-action usage block.

Scope notes (deliberate non-scope)

  • quote is text-only. The post.js / reply.js composer (image upload, file input, base64 fallback) is ~280 + ~270 lines of helpers that are already duplicated between the two files. Lazy-extracting to clis/twitter/utils.js (the 3-sibling threshold from refactor(facebook/notifications): pipeline→func + typed errors + 4 enrichment cols (Phase 3 P5) #1391) is its own PR rather than mixed into write-action symmetry.
  • No new strategy / framework changes. All four adapters use Strategy.UI matching like / bookmark / hide-reply / delete siblings. navigateBefore defaults to true for UI strategy which ensures browser session without auto-pre-nav (re-confirmed in src/registry.ts normalizeCommand).

Test plan

  • npx vitest run clis/twitter/ → 18 / 96 pass
  • npx tsc --noEmit → clean
  • node scripts/check-typed-error-lint.mjs → no new
  • node scripts/check-silent-column-drop.mjs → no new
  • bash scripts/check-doc-coverage.sh → 140/140
  • Manual live smoke (logged-in account) — defer to reviewer or follow-up; UI selectors retweetConfirm / unretweetConfirm are documented X.com testids

Follow-ups in this series (for visibility, not in this PR)

  • P1: search advanced filters (--from / --has / --exclude / --product)
  • P2: bookmarks folders + bookmarks folders <id>
  • P3: --top-by-engagement N flag + weighted scoring helper across read commands
  • P4: dedupe bearer token + FEATURES dict + composer helpers to clis/twitter/utils.js (also adds quote --image)

jackwener added 4 commits May 8, 2026 00:50
… symmetry P0)

Mirror twitter-cli (https://github.com/public-clis/twitter-cli) write-action
coverage. Existing twitter adapters had like / follow/unfollow / bookmark/unbookmark
/ block/unblock pairs but no unlike, no retweet/unretweet, and no quote-tweet.
This PR fills the four gaps.

Adapters

- unlike: mirror like.js — find [data-testid="unlike"], click, verify the
  [data-testid="like"] button reappears. Idempotent (already-unliked = success).
- retweet: two-step UI — click [data-testid="retweet"] to open menu, wait for
  and click [data-testid="retweetConfirm"], verify [data-testid="unretweet"]
  appears. Idempotent.
- unretweet: reverse of retweet — click [data-testid="unretweet"], wait for
  and click [data-testid="unretweetConfirm"], verify [data-testid="retweet"]
  reappears. Idempotent.
- quote: navigate to /compose/post?url=<tweet> (text-only for now; image
  attachment will land with the post.js/reply.js composer-helpers refactor in
  a follow-up). Verifies the quoted card rendered before submit so we never
  silently post a plain tweet without the attachment.

Tests

- 4 new test files, 15 contract assertions covering: ok-path UI flow,
  idempotent already-done branch, fail-path no-rewait, CommandExecutionError
  on missing page, and (for quote) malformed-URL up-front rejection +
  encodeURIComponent round-trip on the ?url= attachment.
- Full clis/twitter test suite: 18 files / 96 tests pass.

Audits / hygiene

- typed-error-lint: 189 → 189 (0 new)
- silent-column-drop: 103 → 103 (0 new)
- doc coverage: 140/140
- docs/adapters/browser/twitter.md updated with the 4 new commands and a
  write-action usage block.

Scope notes

- text-only quote chosen for P0 to keep blast radius small — the post.js /
  reply.js composer (image upload, file input, base64 fallback) is ~280 +
  ~270 lines of helpers that are duplicated across both files. Lazy-extract
  to clis/twitter/utils.js (3-sibling threshold from #1391) is its own PR
  rather than mixed into write-action symmetry.
- All four adapters use Strategy.UI (matching like / bookmark / hide-reply
  / delete siblings); navigateBefore is the registry default for UI which
  ensures browser session without auto-pre-nav.
@jackwener jackwener merged commit 644d451 into main May 7, 2026
11 checks passed
@jackwener jackwener mentioned this pull request May 7, 2026
2 tasks
jackwener added a commit that referenced this pull request May 7, 2026
- bump opencli to 1.7.14 (was 1.7.13)
- extension stays at 1.0.6 (no extension changes since v1.7.13)
- finalize CHANGELOG with the three landed PRs:
  * #1399 daemon restart on stale ready state for npm -g upgrade
  * #1400 twitter write-action symmetry (unlike/retweet/unretweet/quote)
  * #1401 agent-friendly adapter help (drop globally-shared option noise)
jackwener added a commit that referenced this pull request May 7, 2026
…write actions

Shared helper `buildTwitterArticleScopeSource(tweetId)` emits the canonical
exact-match scoping bindings (tweetId, __twTweetPathRe, __twGetStatusIdFromHref,
__twHasLinkToTarget, findTargetArticle) used by every write-action IIFE.

Why
---
PR #1400 (P0) review caught two silent failure classes that were previously
hand-rolled in each adapter:
  1. Substring match on tweet id: `pathname.includes('/status/' + tweetId)`
     would silently match `/status/1234567` when the requested id was `123`.
  2. Loose article match: bare `[data-testid="like"]` on conversation pages
     silently grabs the first article (parent tweet), liking the wrong tweet.

Five sibling adapters (like, bookmark, unbookmark, hide-reply, delete) had
inherited these patterns from delete.js. Four newly-merged P0 adapters
(unlike, retweet, unretweet, quote) inlined a fresh copy of the corrected
helper. Centralising the helper eliminates drift risk and makes the
exact-match guarantee a single review surface.

What changed
------------
- shared.js: export `buildTwitterArticleScopeSource(tweetId)` returning a
  string fragment for embedding inside `page.evaluate(...)` IIFEs.
- shared.test.js: add 7 JSDOM-based tests proving the regex anchors reject
  substring (`123` vs `1234567`) and path-suffix (`/photo/1`) attacks, and
  that `__twHasLinkToTarget` works for the quote-card use case (no <article>
  ancestor).
- like.js, bookmark.js, unbookmark.js, hide-reply.js, delete.js, unlike.js,
  retweet.js, unretweet.js, quote.js: refactored to use the shared helper.
- delete.js: also drops the local `extractTweetId` (which accepted any host)
  in favour of `parseTweetUrl` from shared.js (typed ArgumentError).
- like.test.js, bookmark.test.js, unbookmark.test.js, hide-reply.test.js:
  new test files (these adapters had no tests before).
- delete.test.js, unlike.test.js, retweet.test.js, unretweet.test.js,
  quote.test.js: updated assertions to verify the helper is embedded.

Test
----
All 124 twitter tests pass (was 92 before this change, +32 from the four
new test files and shared.js JSDOM coverage).
typed-error-lint: 189 (unchanged from baseline).
silent-column-drop: 103 (unchanged from baseline).
jackwener added a commit that referenced this pull request May 7, 2026
…agement scoring, sibling dedupe + help docs (#1406)

Round 21 follow-up to #1400 (P0 write-action symmetry, merged `644d4517`). 5 features + help docs unified into one PR per WAWQAQ "全部合成一个 PR" directive.

## Scope

- **P1** (`cf10c098`): `twitter search` `--from / --has / --exclude / --product` filters, mapping to X `from:` / `filter:` / `-filter:` / `f=` operators; legacy `--filter top|live` preserved (--product win on conflict)
- **P2** (`a484a69a`): new `twitter bookmark-folders` + `bookmark-folder <id>`; X Premium GraphQL `bookmarkFoldersSlice` + `BookmarkFolderTimeline`; queryId 三层 fallback (placeholder.json → client-web bundle → pinned constants)
- **P3** (`f209f914`): `--top-by-engagement N` to 7 tweet-shaped read commands (search/timeline/likes/bookmarks/list-tweets/tweets/thread); single helper in `utils.js`; formula `likes×1 + retweets×3 + replies×2 + bookmarks×5 + log10(views+1)×0.5`; **N=0 reference equality no-op** → existing 157 twitter tests 0 churn
- **P4** (`89283fa0`): `TWITTER_BEARER_TOKEN` + composer image helpers extracted to `utils.js` (12 GraphQL adapter dedup); reply hardening; quote adds `--image`
- **P5** (`a3d10a48`): sibling article-scope helper extracted to `shared.js` (9 write commands reuse, dedup with #1400 P0 invariant)
- **docs** (`2a358d80`): help-doc precision (positional-omitted defaults + download/bookmarks/notifications/timeline/lists description thicken; concurrent #1401/#1403 wording preserved)

47 files / +2594/-470. Tests **96 → 216 (+120)**, manifest 798 → 801 (+3), typed-error-lint 190 → 189 (resolved 1 grandfathered sentinel).

## Iteration history (3 review fix commits on top of 6 author commits)

- `7f93779b` — codex-mini1 lead fix1: 3 blocker bundle (P5 host invariant + P2 safe-id + sentinel removal + P1 fallback fail-fast)
- `2a29ecc6` — codex-mini1 lead fix2: P3 help formula consistency (doc/help text matches actual `log10(views+1)×0.5`)
- `df4dcd76` — codex-mini1 lead fix3 (F-P-1 aux catch): P2 `bookmark-folder --limit` upfront validation (`Number(kwargs.limit ?? 20)` + reject non-positive/non-integer + regression `0/negative/fractional/NaN` + `page.goto` zero-call assert)

## 4 progressive blockers caught (codex-mini1 lead 3 rounds + F-P-1 aux 1 round)

1. **P5 host invariant gap** (lead): article-scope helper preserved exact `/status/<id>` path but ignored link host → off-domain `https://evil.com/alice/status/<target>` would satisfy `__twHasLinkToTarget`. Fixed: `https` + X/Twitter host or subdomain + exact `/status/<id>` or `/i/status/<id>` path; query/hash allowed; off-domain/host-suffix/non-https/path-suffix/substring-id rejected; JSDOM positive + 5 negative anchors.

2. **P2 listing→detail round-trip + sentinel** (lead): `bookmark-folders` accepted opaque IDs but `bookmark-folder <id>` only accepted numeric → round-trip broken; new `author: 'unknown'` sentinel created fabricated author URL. Fixed: `[A-Za-z0-9_-]+` opaque safe-id (rejects `/`, `?`, `%`, spaces) + `resolveTwitterQueryId()` sanitization for queryId resolution; sentinel removed → empty author + canonical `/i/status/<id>` URL.

3. **P1 fallback silent tab miss** (lead): pushState fail → fallback typing into search box, `clickProductTabIfNeeded()` silent return on tab not found → user `--product photos` silently degraded to Top results. Fixed: throw `CommandExecutionError` when requested `--product` tab cannot be selected + invalid `--from` / `--limit` upfront pre-nav reject + double-direction tests.

4. **P2 limit silent normalize** (aux): `const limit = kwargs.limit || 20` → `--limit 0` silent → 20; negative/non-integer pre-IO unchecked. Fixed: `Number(kwargs.limit ?? 20)` + require positive integer before `page.goto` + regression covers `0/negative/fractional/NaN` + `page.goto` zero-call.

## Cultural sediment (Round 21 audit checklist 7 rules / 6 dimensions)

This PR **immediately validated 4 of 7 rules** in review pipeline:
- (b) silent-clamp class — P1 fallback silent tab miss (silent semantic-downgrade) + P2 `|| 20` silent normalize
- (e) ID exact-not-substring — P5 host invariant (was only path-exact, not host-exact)
- (f) grandfathered-not-exempt — P5 helper-refactor boundary lost host invariant + P2 new adapter inherited grandfathered `'unknown'` sentinel
- (g) fallback-must-have-success-criterion — P1 fallback path missing post-condition assertion

7 rules / 6 dimensions:
- (a) cross-grep sibling URL pattern — structural
- (b) silent-clamp class — failure mode (input)
- (c) broad querySelector → article-scoping — scope
- (d) missing-validation early reject — boundary
- (e) ID exact-not-substring — identity
- (f) grandfathered-not-exempt (corollary: applies to new file + new helper-refactor boundary; not original-file line-edit) — time-axis
- (g) fallback-must-have-success-criterion (sub-rule g': fallback unit test must include post-condition assertion, not just "doesn't throw") — failure mode (output)

**Cross-PR validation 4-chain on meta-anchor "Structural exactness for identity matching"**:
- #1391 URL layer (`isFacebookAuthRedirectPath`: top-level anchor + `\.php` + `(/|$)` segment edge)
- #1392 URL parser layer (`parseGrokSessionId`: bare UUID exact / URL host-exact-or-subdomain + path-exact)
- #1400 DOM layer (article-scoping: status-id `/\/status\/${id}(?:\/|$)/` regex / segment-array exact)
- #1406 P5 helper-refactor boundary (full URL invariant in shared helper: host+path re-anchored after extraction)
- Common invariant: boundary-lock structural shape; **fuzzy match is silent-failure 温床**; lesson lifecycle = surface-shift not add-and-forget.

**Audit framework self-discipline**: each rule must have grep-able detection signal, otherwise rule degenerates to mantra. Framework is "7 rules + sub-instance pattern in new surface", not frozen 7 rules.

**Round 17 race-mitigation 第 9 连续 race-free execution**: standard alternation cadence (#1400 A 组 → #1406 B 组), lead final + aux final + `@pr-monitor squash?` trigger, pr-monitor proactive ack + serial squash, lead silent on closeout.

## Validation gates (final head `df4dcd76`)

Local: Twitter adapter tests `25 files / 216 tests`, focused P1/P2/P3/P5 tests `99/99`, `node --check` touched runtime, `npx tsc --noEmit`, `npm run build`, manifest 801 entries, typed-error-lint `189/189`, silent-column-drop `103/103`, doc-coverage `140/140`, docs:build clean, listing-id advisory `13` unchanged (wikipedia/trending residual non-Twitter), `git diff --check` clean.

GitHub: build×3 (ubuntu/macos/windows) SUCCESS, unit-test shards SUCCESS, bun-test SUCCESS, adapter-test SUCCESS, audit SUCCESS, doc-coverage SUCCESS, docs-build SUCCESS, smoke-test skipped, PR `CLEAN/MERGEABLE`.

Reviewers:
- Lead: @codex-mini1 (3 fix rounds, all caught proactively + amend P3 help consistency)
- Aux: @First-principles-1 (better-solution triangulation on P2 queryId 三层 fallback + P5 invariant + P3 N=0 reference no-op + caught P2 limit silent normalize)
- Author: @opencli-user (5-feature scope + 7-rule sediment co-author + corollary contributor)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant