Skip to content

feat: rdt post β€” drafts, captcha-gated publishing, images (inline/attached), flair - #19

Open
nikicat wants to merge 8 commits into
public-clis:mainfrom
nikicat:feat/recaptcha-solver
Open

feat: rdt post β€” drafts, captcha-gated publishing, images (inline/attached), flair#19
nikicat wants to merge 8 commits into
public-clis:mainfrom
nikicat:feat/recaptcha-solver

Conversation

@nikicat

@nikicat nikicat commented Jul 22, 2026

Copy link
Copy Markdown

Summary

Adds full post-creation support to rdt-cli:

  • rdt post <sub|u_name> <title> β€” text (--text), link (--url), or image (--image) posts to subreddits and profiles; --draft saves headlessly (text/link only β€” Reddit drafts can't store images).
  • reCAPTCHA-gated publishing β€” Reddit gates post submission behind invisible reCAPTCHA Enterprise. Pass a browser-captured --recaptcha-token, or configure a Solvecaptcha API key (env RDT_SOLVECAPTCHA_API_KEY / APIKEY_SOLVECAPTCHA or config file) and a token is bought automatically right before publishing.
  • Inline images in text posts β€” --embed FILE + ![img] markers publish editor-style RTJSON (content.richText): real inline image blocks with captions (new rdt_cli/richtext.py). Drafts fall back to markdown i.redd.it links.
  • Attached image β€” --text + --image attaches via image.url: renders after the body and doubles as the feed-card preview (inline embeds never show on feed cards). Combines with --embed for both.
  • Post flair β€” rdt flairs <sub> lists templates (flagging empty-default ones, which render blank without text); --flair-id/--flair-text apply flair at publish.
  • Community publish fix β€” CreatePost input now mirrors captured live editor requests (subredditName instead of t5_ id, no postType, isCommercialCommunication/targetLanguage always sent); deviating from that shape 500s.
  • Transport hardening β€” 5xx error bodies surface in the raised error; the write transport fails fast on 5xx (mutations aren't idempotent, captcha tokens are single-use) while reads keep exponential backoff.

All GraphQL payload shapes were derived from captured live browser requests and live-verified: md/richtext Γ— community/profile publishes, hybrid embed+attach, and flair application.

Test plan

  • uv run pytest β€” 168 tests passing (payload shapes, CLI validation, transport retry/fail-fast, RTJSON building)
  • uv run ruff check β€” clean
  • Live-tested against reddit.com (r/test + profile posts)

πŸ€– Generated with Claude Code

nikicat and others added 8 commits July 22, 2026 02:49
Add a `rdt post` command that creates Reddit posts through Reddit's web
GraphQL endpoint (svc/shreddit/graphql), reusing the existing cookie session.

Drafts (text/link) are created fully headlessly. Publishing (subreddit
`CreatePost` / profile `CreateProfilePost`, incl. images) is gated behind
reCAPTCHA Enterprise, so it requires a browser-supplied `--recaptcha-token`;
the tool never solves the captcha itself. Image drafts are rejected because
Reddit's draft input type has no media field.

Key pieces:
- transports: TLS fix β€” httpx's default cipher list produces a ClientHello
  that Reddit's WAF blocks on the GraphQL endpoint (403 "blocked by network
  security"); reset to ssl.create_default_context() + set_ciphers("DEFAULT").
  Pure-Python, no new deps, not impersonation. Also allow application/json
  writes on the write transport.
- session: ensure_csrf_token() β€” double-submit CSRF (same value in cookie
  and JSON body; generated if absent).
- client: _graphql, resolve_subreddit_id, create_media_lease, upload_image
  (S3 multipart), create_post, create_draft.
- commands/submit.py: the `post` command (--text/--url/--image, --draft,
  --recaptcha-token, --nsfw/--spoiler, --json/--yaml; profile via u_<name>).
- tests + README/SKILL docs.

No new runtime dependencies.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
`rdt post` publishing no longer requires a browser-captured token: when a
Solvecaptcha API key is configured (env RDT_SOLVECAPTCHA_API_KEY or
APIKEY_SOLVECAPTCHA), a reCAPTCHA Enterprise token is bought automatically
right before CreatePost/CreateProfilePost. An explicit --recaptcha-token
still wins; without either, publish fails fast with guidance (drafts
unchanged).

The solver speaks the solvecaptcha.com API (2captcha-compatible in.php /
res.php β€” the same protocol the solvecaptcha-python package wraps) directly
over httpx, so there are still no new runtime dependencies.

- captcha.py: solve_recaptcha_token() submits a score-based Enterprise task
  (version=v3, enterprise=1, action=post_submit, Reddit's public sitekey)
  and polls res.php for the token; CaptchaSolveError extends RedditApiError
  so the structured error envelope applies. The solve runs as late as
  possible (after image upload) since tokens are single-use, ~2 min TTL.
- constants: RECAPTCHA_SITEKEY (Reddit's public web key), RECAPTCHA_ACTION,
  SOLVECAPTCHA_API_URL, SOLVECAPTCHA_KEY_ENV_VARS.
- submit.py: token precedence flag β†’ solver β†’ usage error; stderr status
  line keeps --json stdout clean.
- tests: solver unit tests (MockTransport: submit/poll/timeout/env
  priority) + command tests (auto-solve, flag precedence, solver failure
  envelope). 149 passed, ruff clean.
- README/SKILL: document the solving flow and env vars.

Co-Authored-By: Claude <noreply@anthropic.com>
Add ~/.config/rdt-cli/config.json (plain JSON) as a persistent home for
the Solvecaptcha API key, so it doesn't have to live in the environment.

- config.py: load_user_config() β€” tolerant loader; a missing, unreadable,
  or non-object file yields {} and never breaks the CLI (warns via logger).
- constants: USER_CONFIG_FILE next to CREDENTIAL_FILE.
- captcha.py: get_solvecaptcha_api_key() now resolves env vars first
  (RDT_SOLVECAPTCHA_API_KEY, APIKEY_SOLVECAPTCHA), then the config file's
  "solvecaptcha_api_key".
- submit.py help/docstrings, README (new "Config File" section), SKILL.md.
- tests: config-file resolution, env-beats-file precedence, missing/invalid
  file tolerance; env-priority test made hermetic against a real on-disk
  config. 152 passed, ruff clean.

Co-Authored-By: Claude <noreply@anthropic.com>
Text (self) posts can't hold media directly, but their markdown can link
Reddit-hosted images. `rdt post --text "step 1 ![img] done" --embed s1.jpg`
uploads each --embed file via the existing S3 lease flow and substitutes
[<file stem>](https://i.redd.it/<mediaId>.<ext>) for the Nth ![img] marker.

- constants: MEDIA_CDN_HOST (i.redd.it serves unsigned; preview.redd.it
  403s without the s= signature β€” verified with curl) + IMAGE_MIME_TO_EXT.
- client: upload_image_as_embed() β€” lease upload β†’ unsigned CDN URL.
- submit.py: --embed (multiple) β€” requires --text; marker count must match
  the embed count, and stray ![img] markers without --embed are rejected,
  so no literal marker leaks into a post. Substitution via _embed_images();
  uploads run before the captcha solve to keep the token fresh.
- tests: kind/marker validation, publish + draft substitution, ext mapping.
  158 passed, ruff clean.
- README/SKILL: --embed row + notes.

Note: inline rendering on new Reddit is expected (real inline-image posts
are served in markdown as reddit-media links) but is pending live
verification with a real post.

Co-Authored-By: Claude <noreply@anthropic.com>
- --embed now publishes editor-style RTJSON (content.richText replaces
  markdown): each ![img] marker becomes a real inline image block with the
  file stem as caption. Drafts stay markdown with i.redd.it CDN links.
- --text + --image attaches an image via image.url: it renders after the
  body and doubles as the feed-card preview (inline embeds never show in
  feed cards). Both flags combine: attachment drives the feed card, embeds
  render in place (live-verified).
- Community CreatePost now mirrors captured editor requests: address by
  subredditName (not the t5_ id, which drafts keep), drop postType, always
  send isCommercialCommunication/targetLanguage. Deviating from that shape
  was the cause of opaque HTTP 500s on community publishes.
- WriteTransport fails fast on 5xx (mutations aren't idempotent, recaptcha
  tokens are single-use) and surfaces the response body in the error; reads
  keep exponential backoff. Retry loop split into _send/_backoff/_terminal
  and no longer sleeps after the final attempt.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- `rdt flairs <subreddit>` lists post flair templates (link_flair_v2.json):
  id, text, editability. Templates with empty default text are flagged β€”
  they apply fine but render as a blank label unless --flair-text fills
  them in.
- `rdt post --flair-id <id> [--flair-text "…"]` attaches flair at publish
  via the CreatePost `flair.{id,text}` input (confirmed live: the template
  id and text land on link_flair_template_id/link_flair_text). Community
  publishes only: rejected for drafts (not stored) and profile posts.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
New command fetching /r/{sub}/about/rules.json: rich panel render
(rule name, applies-to, description), --json/--yaml structured
output, works without auth.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
POST /api/del with the resolved fullname (short index, bare post ID,
or t1_/t3_ fullname). Confirmation prompt by default; -y/--yes skips
it for scripted use. Note: Reddit returns 200 even for things that
don't exist or aren't yours, so success output means "accepted", not
"verified deleted".

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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