feat: rdt post β drafts, captcha-gated publishing, images (inline/attached), flair - #19
Open
nikicat wants to merge 8 commits into
Open
feat: rdt post β drafts, captcha-gated publishing, images (inline/attached), flair#19nikicat wants to merge 8 commits into
nikicat wants to merge 8 commits into
Conversation
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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;--draftsaves headlessly (text/link only β Reddit drafts can't store images).--recaptcha-token, or configure a Solvecaptcha API key (envRDT_SOLVECAPTCHA_API_KEY/APIKEY_SOLVECAPTCHAor config file) and a token is bought automatically right before publishing.--embed FILE+![img]markers publish editor-style RTJSON (content.richText): real inline image blocks with captions (newrdt_cli/richtext.py). Drafts fall back to markdowni.redd.itlinks.--text+--imageattaches viaimage.url: renders after the body and doubles as the feed-card preview (inline embeds never show on feed cards). Combines with--embedfor both.rdt flairs <sub>lists templates (flagging empty-default ones, which render blank without text);--flair-id/--flair-textapply flair at publish.CreatePostinput now mirrors captured live editor requests (subredditNameinstead oft5_id, nopostType,isCommercialCommunication/targetLanguagealways sent); deviating from that shape 500s.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π€ Generated with Claude Code