From 06186b444a7a8d9d3c78a290c3b3584b47c5e4df Mon Sep 17 00:00:00 2001 From: Nikolay Bryskin Date: Wed, 22 Jul 2026 02:49:57 +0300 Subject: [PATCH 1/8] =?UTF-8?q?feat:=20add=20`rdt=20post`=20=E2=80=94=20cr?= =?UTF-8?q?eate=20post=20drafts=20and=20(captcha-gated)=20publish?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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_). - tests + README/SKILL docs. No new runtime dependencies. Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 17 ++- SKILL.md | 17 ++- rdt_cli/cli.py | 6 +- rdt_cli/client.py | 186 +++++++++++++++++++++++++++- rdt_cli/commands/submit.py | 173 ++++++++++++++++++++++++++ rdt_cli/constants.py | 24 ++++ rdt_cli/session.py | 17 +++ rdt_cli/transports.py | 34 +++++- tests/test_cli.py | 243 ++++++++++++++++++++++++++++++++++++- 9 files changed, 708 insertions(+), 9 deletions(-) create mode 100644 rdt_cli/commands/submit.py diff --git a/README.md b/README.md index 4373ace..21cd9fb 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,7 @@ A CLI for Reddit โ€” browse feeds, read posts, search, and interact via reverse- - ๐Ÿ“ค **Export** โ€” export search results to CSV or JSON; `-o file.json` on any listing - ๐Ÿ‘ค **Users** โ€” view user profiles, post history, comment history, saved and upvoted items - โฌ†๏ธ **Interactions** โ€” upvote/downvote, save/unsave, subscribe/unsubscribe, comment (with 1.5-4s rate-limit delay) +- โœ๏ธ **Create posts** โ€” save text/link **drafts** headlessly (`rdt post --draft`); publishing is reCAPTCHA-gated and needs a browser token (`--recaptcha-token`) - ๐Ÿ›ก๏ธ **Anti-detection** โ€” consistent Chrome 133 fingerprint, `sec-ch-ua` alignment, Gaussian jitter, exponential backoff - ๐Ÿ“Š **Structured output** โ€” `--yaml`, `--json`, `--output FILE`, `--compact`, `--full-text` - ๐Ÿ“ฆ **Stable envelope** โ€” see [SCHEMA.md](./SCHEMA.md) for `ok/schema_version/data/error` @@ -120,8 +121,21 @@ rdt save 3 --undo # Unsave rdt subscribe python # Subscribe to r/python rdt subscribe python --undo # Unsubscribe rdt comment 3 "Great post!" # Comment on result #3 + +# โ”€โ”€โ”€ Create posts (require login) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +# Drafts save headlessly. Publishing is gated behind reCAPTCHA Enterprise, so it +# needs a --recaptcha-token captured from a browser (single-use, ~2 min). +rdt post python "WIP title" --text "Hello **world**" --draft # Save a text draft +rdt post news "Cool read" --url https://example.com --draft # Save a link draft +rdt post python "Title" --text "body" --recaptcha-token # Publish text post +rdt post pics "My cat" --image cat.jpg --recaptcha-token # Publish image post +rdt post u_yourname "On my profile" --text "hi" --recaptcha-token ``` +> **Note:** Reddit drafts store text/link only โ€” not images (image drafts aren't +> supported by Reddit). And publishing any post requires a reCAPTCHA Enterprise +> token that only a browser can produce; rdt-cli never solves the captcha. + ## Authentication rdt-cli supports browser cookie extraction to authenticate with Reddit: @@ -219,7 +233,8 @@ rdt_cli/ โ”œโ”€โ”€ browse.py # feed, popular, all, sub, sub-info, user, user-posts, user-comments, saved, upvoted, open โ”œโ”€โ”€ post.py # read, show โ”œโ”€โ”€ search.py # search, export - โ””โ”€โ”€ social.py # upvote, save, subscribe, comment + โ”œโ”€โ”€ social.py # upvote, save, subscribe, comment + โ””โ”€โ”€ submit.py # post (text/link/image, drafts) ``` ## Development diff --git a/SKILL.md b/SKILL.md index 512e2c3..4ef195a 100644 --- a/SKILL.md +++ b/SKILL.md @@ -73,7 +73,7 @@ Payloads live under `.data`. - `--output file.json` โ†’ save structured output to file - Rich output โ†’ **stderr** (safe for pipes: `rdt search X --json | jq .data`) - Most read commands work without auth (public Reddit JSON API) -- Write actions (upvote, save, subscribe) require auth + built-in 1.5-4s delay +- Write actions (upvote, save, subscribe, comment, post) require auth + built-in 1.5-4s delay ## Command Reference @@ -126,6 +126,21 @@ Payloads live under `.data`. | `rdt subscribe --undo` | Unsubscribe | `rdt subscribe python --undo` | | `rdt comment ` | Post a comment | `rdt comment 3 "Great post!"` | +### Create posts (require auth) + +Exactly one of `--text` / `--url` / `--image`. Target a subreddit or a profile (`u_`). Add `--json` for a structured result. + +**Drafts** save headlessly. **Publishing** is gated behind reCAPTCHA Enterprise (invisible, score-based) โ€” it needs a `--recaptcha-token` captured from a browser (single-use, ~2 min). rdt-cli never solves the captcha. + +| Command | Description | Example | +|---------|-------------|---------| +| `rdt post --text <body> --draft` | Save a text draft | `rdt post python "WIP" --text "..." --draft` | +| `rdt post <sub> <title> --url <url> --draft` | Save a link draft | `rdt post news "Read" --url https://ex.com --draft` | +| `rdt post <sub> <title> --text <body> --recaptcha-token <tok>` | Publish text post | `rdt post python "Hi" --text "..." --recaptcha-token <tok>` | +| `rdt post <sub> <title> --image <file> --recaptcha-token <tok>` | Publish image post | `rdt post pics "Cat" --image cat.jpg --recaptcha-token <tok>` | + +Optional flags: `--nsfw`, `--spoiler`. Notes: Reddit drafts can't store images (text/link only); `--image` requires publishing with a token. + ### Account | Command | Description | diff --git a/rdt_cli/cli.py b/rdt_cli/cli.py index 2a3cf55..4ccb3eb 100644 --- a/rdt_cli/cli.py +++ b/rdt_cli/cli.py @@ -17,7 +17,7 @@ import click from . import __version__ -from .commands import auth, browse, post, search, social +from .commands import auth, browse, post, search, social, submit @click.group() @@ -71,6 +71,10 @@ def cli(ctx: click.Context, verbose: bool) -> None: cli.add_command(social.subscribe) cli.add_command(social.comment) +# โ”€โ”€โ”€ Create commands โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +cli.add_command(submit.post) + if __name__ == "__main__": cli() diff --git a/rdt_cli/client.py b/rdt_cli/client.py index 7762976..e84f981 100644 --- a/rdt_cli/client.py +++ b/rdt_cli/client.py @@ -3,15 +3,28 @@ from __future__ import annotations import logging +import mimetypes +import uuid +from pathlib import Path from typing import Any +import httpx + from .config import DEFAULT_CONFIG, RuntimeConfig from .constants import ( ALL_URL, + BASE_URL, COMMENT_URL, DEFAULT_LIMIT, + GRAPHQL_URL, HOME_URL, + IMAGE_MIME_TYPES, + MEDIA_S3_HOST, MORECHILDREN_URL, + OP_CREATE_DRAFT, + OP_CREATE_POST, + OP_CREATE_PROFILE_POST, + OP_MEDIA_LEASE, POPULAR_URL, POST_COMMENTS_SHORT_URL, POST_COMMENTS_URL, @@ -34,7 +47,7 @@ ) from .fingerprint import BrowserFingerprint from .session import SessionState -from .transports import ReadTransport, WriteTransport +from .transports import SSL_CONTEXT, ReadTransport, WriteTransport logger = logging.getLogger(__name__) @@ -351,6 +364,177 @@ def post_comment(self, parent_fullname: str, text: str) -> dict: """Post a comment.""" return self._post(COMMENT_URL, data={"parent": parent_fullname, "text": text}) + # โ”€โ”€ Post creation (GraphQL) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + def _graphql(self, operation: str, variables: dict[str, Any]) -> Any: + """POST a shreddit GraphQL operation and return its ``data`` payload. + + Sends ``{operation, variables, csrf_token}`` as JSON. The csrf_token is + echoed into the write transport's cookie jar so cookie == body (Reddit's + double-submit CSRF). Raises RedditApiError on GraphQL-level errors. + """ + if self._write_transport is None: + raise RuntimeError("Client not initialized. Use 'with RedditClient() as client:'") + token = self.session.ensure_csrf_token() + self._write_transport.client.cookies.set("csrf_token", token) + payload = {"operation": operation, "variables": variables, "csrf_token": token} + result = self._write_request("POST", GRAPHQL_URL, json=payload) + if isinstance(result, dict) and result.get("errors"): + errors = result["errors"] + msg = "; ".join( + e.get("message", str(e)) if isinstance(e, dict) else str(e) for e in errors + ) or str(errors) + raise RedditApiError(f"GraphQL {operation} failed: {msg}") + if isinstance(result, dict): + return result.get("data", result) + return result + + @staticmethod + def _markdown_content(body: str | None) -> dict[str, Any]: + """Content payload for a markdown body (empty dict for no body).""" + return {"markdown": body} if body else {} + + def resolve_subreddit_id(self, subreddit: str) -> str: + """Resolve a subreddit name to its ``t5_`` fullname (for GraphQL input).""" + about = self.get_subreddit_about(subreddit) + sub_id = about.get("name") if isinstance(about, dict) else None + if not sub_id: + raise RedditApiError(f"Could not resolve subreddit r/{subreddit}") + return sub_id + + def create_media_lease(self, mimetype_token: str) -> dict[str, Any]: + """Request an S3 upload lease for an image (``mimetype_token`` e.g. 'JPEG').""" + data = self._graphql(OP_MEDIA_LEASE, {"input": {"mimetype": mimetype_token}}) + lease = data.get("createMediaUploadLease") if isinstance(data, dict) else None + if not isinstance(lease, dict) or not lease.get("uploadLease"): + raise RedditApiError("Media lease request returned no upload lease") + return lease + + def upload_image(self, path: str) -> str: + """Upload an image to Reddit's S3 and return its media asset id. + + Flow: lease (GraphQL) โ†’ multipart POST to the S3 URL from the lease + (lease fields verbatim + ``file`` last, no reddit cookies) โ†’ mediaId. + """ + file_path = Path(path) + if not file_path.is_file(): + raise RedditApiError(f"Image file not found: {path}") + content_type, _ = mimetypes.guess_type(file_path.name) + if content_type not in IMAGE_MIME_TYPES: + raise RedditApiError( + f"Unsupported image type for {path} (allowed: {', '.join(sorted(IMAGE_MIME_TYPES))})" + ) + token = content_type.split("/")[-1].upper() # image/jpeg โ†’ JPEG + + lease = self.create_media_lease(token) + upload_lease = lease["uploadLease"] + upload_url = upload_lease["uploadLeaseUrl"] + if upload_url.startswith("//"): + upload_url = "https:" + upload_url + fields = {h["header"]: h["value"] for h in upload_lease.get("uploadLeaseHeaders", [])} + media_id = lease.get("mediaId") + if not media_id: + raise RedditApiError("Media lease did not return a mediaId") + + # Separate client: cross-site S3 host, must NOT carry reddit cookies. + headers = { + "User-Agent": self._fingerprint.user_agent, + "Origin": BASE_URL, + "Referer": f"{BASE_URL}/", + } + with httpx.Client( + follow_redirects=True, timeout=httpx.Timeout(self._timeout), verify=SSL_CONTEXT + ) as s3: + resp = s3.post( + upload_url, + data=fields, + files={"file": (file_path.name, file_path.read_bytes(), content_type)}, + headers=headers, + ) + if resp.status_code not in (200, 201): + raise RedditApiError(f"Image upload failed (HTTP {resp.status_code})") + return media_id + + def create_post( + self, + subreddit_id: str, + title: str, + *, + recaptcha_token: str, + kind: str = "self", + body: str | None = None, + url: str | None = None, + media_id: str | None = None, + nsfw: bool = False, + spoiler: bool = False, + is_profile: bool = False, + ) -> Any: + """Publish a post (subreddit ``CreatePost`` / profile ``CreateProfilePost``). + + Reddit gates post submission behind **reCAPTCHA Enterprise** (invisible, + score-based, action ``post_submit``). ``recaptcha_token`` MUST be a fresh + token obtained from a browser (single-use, ~2 min TTL) โ€” it cannot be + produced headlessly, so this call fails without one. Field shapes come + from captured live requests + ``ValidateCreatePostInput``: + selfโ†’``content.markdown``, linkโ†’``url``, imageโ†’``gallery.items[].mediaId`` + (subreddit) or ``image.url`` (profile). + """ + inp: dict[str, Any] = { + "title": title, + "isNsfw": bool(nsfw), + "isSpoiler": bool(spoiler), + "content": self._markdown_content(body) if kind == "self" else {}, + "recaptchaToken": recaptcha_token, + "correlationId": str(uuid.uuid4()), + } + if is_profile: + inp["isCommercialCommunication"] = False + inp["targetLanguage"] = "" + if kind == "link": + inp["url"] = url + elif kind == "image": + inp["image"] = {"url": f"{MEDIA_S3_HOST}/{media_id}"} + return self._graphql(OP_CREATE_PROFILE_POST, {"input": inp}) + + inp["subredditId"] = subreddit_id + inp["postType"] = {"self": "TEXT", "link": "LINK", "image": "IMAGE"}[kind] + if kind == "link": + inp["url"] = url + elif kind == "image": + inp["gallery"] = {"items": [{"mediaId": media_id}]} + return self._graphql(OP_CREATE_POST, {"input": inp}) + + def create_draft( + self, + subreddit_id: str, + title: str, + *, + body: str | None = None, + url: str | None = None, + nsfw: bool = False, + spoiler: bool = False, + ) -> Any: + """Save a post draft via ``CreateDraft`` (text or link โ€” no captcha needed). + + Reddit drafts store text (markdown) or a link URL only; they cannot hold + images (the draft input type has no media field). Text is confirmed live; + the link shape is best-effort. + """ + inp: dict[str, Any] = { + "subredditId": subreddit_id, + "title": title, + "isNsfw": bool(nsfw), + "isSpoiler": bool(spoiler), + } + if url: + inp["kind"] = "LINK" + inp["url"] = url + inp["content"] = {} + else: + inp["kind"] = "MARKDOWN" + inp["content"] = self._markdown_content(body) + return self._graphql(OP_CREATE_DRAFT, {"input": inp}) + # โ”€โ”€ Subscription feed โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ def get_my_subscriptions( diff --git a/rdt_cli/commands/submit.py b/rdt_cli/commands/submit.py new file mode 100644 index 0000000..2b7ae7b --- /dev/null +++ b/rdt_cli/commands/submit.py @@ -0,0 +1,173 @@ +"""Post creation: draft (headless) and publish (reCAPTCHA-gated) via GraphQL. + +Reddit's web GraphQL (see rdt_cli.client) lets us create **drafts** headlessly, +but **publishing** a post is gated behind reCAPTCHA Enterprise (invisible, +score-based, action ``post_submit``). A valid token can only be produced by a +real browser, so publishing requires the caller to pass ``--recaptcha-token`` +(obtained from DevTools, single-use, ~2 min TTL). The tool never solves the +captcha itself. Without a token, use ``--draft``. +""" + +from __future__ import annotations + +from typing import Any + +import click + +from ..client import RedditClient +from ..constants import BASE_URL +from ..exceptions import RedditApiError +from ._common import ( + console, + exit_for_error, + maybe_print_structured, + require_auth, + structured_output_options, + write_delay, +) + + +def _find_first(obj: Any, keys: set[str]) -> str | None: + """Depth-first search for the first non-empty string under any of ``keys``.""" + if isinstance(obj, dict): + for key, value in obj.items(): + if key in keys and isinstance(value, str) and value: + return value + for value in obj.values(): + found = _find_first(value, keys) + if found: + return found + elif isinstance(obj, list): + for item in obj: + found = _find_first(item, keys) + if found: + return found + return None + + +def _permalink_from(data: Any) -> str | None: + """Best-effort extraction of a post permalink from a GraphQL response.""" + permalink = _find_first(data, {"permalink"}) or _find_first(data, {"postUrl"}) + if permalink and permalink.startswith("/"): + permalink = f"{BASE_URL}{permalink}" + return permalink + + +_RECAPTCHA_HELP = ( + "Publishing requires a reCAPTCHA Enterprise token โ€” Reddit gates post " + "submission with invisible, score-based reCAPTCHA (action 'post_submit'). " + "A token can only be produced by a browser (single-use, ~2 min). Pass it via " + "--recaptcha-token, or use --draft to save a draft headlessly." +) + + +@click.command() +@click.argument("subreddit") +@click.argument("title") +@click.option("--text", "text", default=None, help="Self/text post body (markdown)") +@click.option("--url", "link_url", default=None, help="Link post URL") +@click.option( + "--image", "image", + type=click.Path(exists=True, dir_okay=False), + default=None, + help="Image file to post (publish only; drafts can't store images)", +) +@click.option("--draft", is_flag=True, help="Save as a draft instead of publishing (text/link only)") +@click.option( + "--recaptcha-token", "recaptcha_token", default=None, + help="reCAPTCHA Enterprise token from a browser (required to publish)", +) +@click.option("--nsfw", is_flag=True, help="Mark as NSFW") +@click.option("--spoiler", is_flag=True, help="Mark as spoiler") +@structured_output_options +def post( + subreddit: str, + title: str, + text: str | None, + link_url: str | None, + image: str | None, + draft: bool, + recaptcha_token: str | None, + nsfw: bool, + spoiler: bool, + as_json: bool, + as_yaml: bool, +) -> None: + """Create a post in a subreddit or profile (u_<name>): text, link, or image. + + Provide exactly one of --text, --url, or --image. + + Drafts (--draft) are created headlessly. Publishing is gated behind reCAPTCHA + Enterprise, so it needs a --recaptcha-token captured from a browser. + + Examples: + rdt post python "My title" --text "Hello **world**" --draft + rdt post news "Interesting" --url https://example.com --draft + rdt post pics "My cat" --image cat.jpg --recaptcha-token <token> + rdt post u_myname "On my profile" --text "hi" --recaptcha-token <token> + """ + provided = [ + name for name, given in + (("--text", text is not None), ("--url", link_url is not None), ("--image", image is not None)) + if given + ] + if len(provided) != 1: + raise click.UsageError("Provide exactly one of --text, --url, or --image.") + kind = {"--text": "self", "--url": "link", "--image": "image"}[provided[0]] + + if draft and kind == "image": + raise click.UsageError( + "--image cannot be combined with --draft: Reddit drafts cannot store " + "images (the image only attaches at publish time). Publish it with " + "--recaptcha-token, or draft text/link only." + ) + if not draft and not recaptcha_token: + raise click.UsageError(_RECAPTCHA_HELP) + + is_profile = subreddit.lower().startswith("u_") + + cred = require_auth() + try: + with RedditClient(cred) as client: + client.validate_session() + subreddit_id = client.resolve_subreddit_id(subreddit) + if draft: + data = client.create_draft( + subreddit_id, title, body=text, url=link_url, nsfw=nsfw, spoiler=spoiler, + ) + action = "Draft saved" + else: + media_id = client.upload_image(image) if image is not None else None + data = client.create_post( + subreddit_id, title, recaptcha_token=recaptcha_token or "", kind=kind, + body=text, url=link_url, media_id=media_id, nsfw=nsfw, spoiler=spoiler, + is_profile=is_profile, + ) + action = "Posted" + write_delay() + + permalink = _permalink_from(data) + draft_id = _find_first(data, {"id"}) if draft else None + payload: dict[str, Any] = { + "action": "draft" if draft else "post", + "subreddit": subreddit, + "title": title, + "kind": kind, + "result": data, + } + if permalink: + payload["permalink"] = permalink + if draft_id: + payload["draft_id"] = draft_id + + if maybe_print_structured(payload, as_json=as_json, as_yaml=as_yaml): + return + target = permalink or draft_id or f'"{title}"' + console.print(f"[green]โœ… {action}[/green] to {subreddit}: {target}") + if draft: + console.print( + f"[dim]Drafts can't be published headlessly (reCAPTCHA). " + f"Finish in browser: {BASE_URL}/r/{subreddit}/submit[/dim]" + ) + except RedditApiError as exc: + exit_for_error(exc, as_json=as_json, as_yaml=as_yaml, prefix="Post failed") diff --git a/rdt_cli/constants.py b/rdt_cli/constants.py index 055064b..0da40e1 100644 --- a/rdt_cli/constants.py +++ b/rdt_cli/constants.py @@ -51,6 +51,30 @@ COMMENT_URL = "/api/comment" SUBSCRIPTIONS_URL = "/subreddits/mine/subscriber.json" +# โ”€โ”€ Post creation (Reddit web GraphQL) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +# Modern Reddit ("shreddit") creates posts/drafts and leases media uploads +# through a single GraphQL endpoint on www.reddit.com. JSON body is +# {operation, variables, csrf_token}; csrf_token is double-submit (same value +# in the cookie jar and the body). See rdt_cli.client for the flow. +GRAPHQL_URL = "/svc/shreddit/graphql" +OP_CREATE_POST = "CreatePost" # subreddit posts +OP_CREATE_PROFILE_POST = "CreateProfilePost" # posts to u_<username> profile +OP_CREATE_DRAFT = "CreateDraft" +OP_MEDIA_LEASE = "CreateMediaUploadLease" + +# S3 host that serves uploaded media; the object URL is "<host>/<mediaId>". +MEDIA_S3_HOST = "https://reddit-uploaded-media.s3-accelerate.amazonaws.com" + +# Image posts: allowed content types (extension โ†’ MIME). The lease token sent +# to Reddit is the uppercased subtype (e.g. image/jpeg โ†’ "JPEG"); the actual S3 +# upload URL and fields come from the lease response. +IMAGE_MIME_TYPES = { + "image/png", + "image/jpeg", + "image/gif", + "image/webp", +} + # โ”€โ”€ Request Headers (Chrome 133, macOS) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ HEADERS = { "User-Agent": ( diff --git a/rdt_cli/session.py b/rdt_cli/session.py index 8cda2c9..a1e8fe2 100644 --- a/rdt_cli/session.py +++ b/rdt_cli/session.py @@ -2,6 +2,7 @@ from __future__ import annotations +import secrets from dataclasses import dataclass, field from typing import Any @@ -63,6 +64,22 @@ def refresh_capabilities(self) -> None: self.capabilities = capabilities + def ensure_csrf_token(self) -> str: + """Return a csrf_token for GraphQL writes, generating one if absent. + + Reddit's web GraphQL uses a double-submit CSRF token: the same value + must appear in both the request cookie and the JSON body, and it may be + client-generated (only ``reddit_session`` must be a real login cookie). + Prefer an existing ``csrf_token`` cookie so we match the browser; fall + back to a fresh token otherwise. + """ + token = self.cookies.get("csrf_token") + if not token: + token = secrets.token_hex(16) + self.cookies["csrf_token"] = token + self.refresh_capabilities() + return token + def apply_identity(self, identity: dict[str, Any]) -> None: """Update session from a validated identity payload.""" data = identity.get("data", identity) diff --git a/rdt_cli/transports.py b/rdt_cli/transports.py index 8f135b2..76a3562 100644 --- a/rdt_cli/transports.py +++ b/rdt_cli/transports.py @@ -4,6 +4,7 @@ import logging import random +import ssl import time from typing import Any @@ -24,6 +25,24 @@ logger = logging.getLogger(__name__) +def build_ssl_context() -> ssl.SSLContext: + """SSL context whose TLS fingerprint Reddit's WAF accepts. + + httpx/httpcore ship a curated, restricted cipher list. The resulting + ClientHello fingerprint is rejected (HTTP 403 "blocked by network security") + by Reddit's edge on POST /svc/shreddit/graphql โ€” while reads and the legacy + /api/* endpoints are unaffected. Python's stock cipher set passes, so we + build a default context and reset it to OpenSSL's DEFAULT ciphers. Cert + verification is preserved. + """ + context = ssl.create_default_context() + context.set_ciphers("DEFAULT") + return context + + +SSL_CONTEXT = build_ssl_context() + + class BaseTransport: """Shared retry, throttling, and cookie management.""" @@ -48,6 +67,7 @@ def __init__( cookies=session.cookies, follow_redirects=True, timeout=httpx.Timeout(config.timeout), + verify=SSL_CONTEXT, ) def close(self) -> None: @@ -161,10 +181,16 @@ def request(self, method: str, url: str, **kwargs: Any) -> Any: headers = dict(kwargs.pop("headers", {})) headers.update(self.fingerprint.write_headers(modhash=self.session.modhash)) - kwargs["headers"] = headers - data = kwargs.get("data") - if isinstance(data, dict) and self.session.modhash and "uh" not in data: - kwargs["data"] = {**data, "uh": self.session.modhash} + if "json" in kwargs: + # GraphQL / JSON writes (svc/shreddit/graphql): send application/json + # and do not inject the form-style `uh` modhash. CSRF is carried in + # the JSON body + cookie by the caller. + headers["Content-Type"] = "application/json" + else: + data = kwargs.get("data") + if isinstance(data, dict) and self.session.modhash and "uh" not in data: + kwargs["data"] = {**data, "uh": self.session.modhash} + kwargs["headers"] = headers return super().request(method, url, **kwargs) diff --git a/tests/test_cli.py b/tests/test_cli.py index 50e7df2..d96c3bd 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -37,6 +37,7 @@ def test_all_commands_registered(self): "read", "show", "search", "export", "upvote", "save", "subscribe", "comment", + "post", ] for cmd in expected: assert cmd in result.output, f"Missing command: {cmd}" @@ -52,7 +53,7 @@ def test_command_count(self): # Count command lines (indented, after "Commands:" ) lines = result.output.split("\n") cmd_lines = [line for line in lines if line.startswith(" ") and not line.strip().startswith("-")] - assert len(cmd_lines) >= 22 + assert len(cmd_lines) >= 23 # โ”€โ”€ Command help โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ @@ -70,6 +71,7 @@ class TestCommandHelp: "read", "show", "search", "export", "upvote", "save", "subscribe", "comment", + "post", ], ) def test_help(self, cmd): @@ -972,3 +974,242 @@ def test_show_help_shows_compact(self): assert result.exit_code == 0 assert "--compact" in result.output + +# โ”€โ”€ Post creation command (mocked) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + +class TestPostCommand: + """Test the `post` creation command with mocked client methods.""" + + def _cred(self): + from rdt_cli.auth import Credential + + return Credential(cookies={"reddit_session": "x", "csrf_token": "y"}, username="me") + + def test_text_draft(self): + cred = self._cred() + with patch("rdt_cli.commands._common.get_credential", return_value=cred), \ + patch("rdt_cli.commands.submit.write_delay"), \ + patch("rdt_cli.client.RedditClient.validate_session", return_value={}), \ + patch("rdt_cli.client.RedditClient.resolve_subreddit_id", return_value="t5_abc"), \ + patch( + "rdt_cli.client.RedditClient.create_draft", + return_value={"createPostDraft": {"ok": True, "postDraft": {"id": "d-123"}}}, + ) as mock_draft, \ + patch("rdt_cli.client.RedditClient.create_post") as mock_post: + result = runner.invoke(cli, ["post", "test", "WIP", "--text", "body", "--draft", "--json"]) + assert result.exit_code == 0, result.output + data = json.loads(result.output) + assert data["data"]["action"] == "draft" + assert data["data"]["draft_id"] == "d-123" + _, kwargs = mock_draft.call_args + assert kwargs["body"] == "body" + mock_post.assert_not_called() + + def test_link_draft(self): + cred = self._cred() + with patch("rdt_cli.commands._common.get_credential", return_value=cred), \ + patch("rdt_cli.commands.submit.write_delay"), \ + patch("rdt_cli.client.RedditClient.validate_session", return_value={}), \ + patch("rdt_cli.client.RedditClient.resolve_subreddit_id", return_value="t5_abc"), \ + patch("rdt_cli.client.RedditClient.create_draft", return_value={}) as mock_draft: + result = runner.invoke( + cli, ["post", "test", "A link", "--url", "https://example.com", "--draft", "--json"] + ) + assert result.exit_code == 0, result.output + _, kwargs = mock_draft.call_args + assert kwargs["url"] == "https://example.com" + + def test_publish_requires_recaptcha_token(self): + cred = self._cred() + with patch("rdt_cli.commands._common.get_credential", return_value=cred): + result = runner.invoke(cli, ["post", "test", "T", "--text", "hi"]) + assert result.exit_code == 2 + assert "reCAPTCHA" in result.output + + def test_text_publish_with_token(self): + cred = self._cred() + with patch("rdt_cli.commands._common.get_credential", return_value=cred), \ + patch("rdt_cli.commands.submit.write_delay"), \ + patch("rdt_cli.client.RedditClient.validate_session", return_value={}), \ + patch("rdt_cli.client.RedditClient.resolve_subreddit_id", return_value="t5_abc"), \ + patch("rdt_cli.client.RedditClient.create_post", return_value={}) as mock_post: + result = runner.invoke( + cli, ["post", "test", "T", "--text", "hi", "--recaptcha-token", "TOK", "--json"] + ) + assert result.exit_code == 0, result.output + _, kwargs = mock_post.call_args + assert kwargs["kind"] == "self" + assert kwargs["recaptcha_token"] == "TOK" + assert kwargs["is_profile"] is False + + def test_image_publish_with_token(self, tmp_path): + cred = self._cred() + img = tmp_path / "pic.png" + img.write_bytes(b"\x89PNG\r\n\x1a\n") + with patch("rdt_cli.commands._common.get_credential", return_value=cred), \ + patch("rdt_cli.commands.submit.write_delay"), \ + patch("rdt_cli.client.RedditClient.validate_session", return_value={}), \ + patch("rdt_cli.client.RedditClient.resolve_subreddit_id", return_value="t5_abc"), \ + patch("rdt_cli.client.RedditClient.upload_image", return_value="media123") as mock_up, \ + patch("rdt_cli.client.RedditClient.create_post", return_value={}) as mock_post: + result = runner.invoke( + cli, ["post", "test", "Pic", "--image", str(img), "--recaptcha-token", "TOK", "--json"] + ) + assert result.exit_code == 0, result.output + mock_up.assert_called_once_with(str(img)) + _, kwargs = mock_post.call_args + assert kwargs["kind"] == "image" + assert kwargs["media_id"] == "media123" + + def test_profile_publish_uses_profile_flag(self): + cred = self._cred() + with patch("rdt_cli.commands._common.get_credential", return_value=cred), \ + patch("rdt_cli.commands.submit.write_delay"), \ + patch("rdt_cli.client.RedditClient.validate_session", return_value={}), \ + patch("rdt_cli.client.RedditClient.resolve_subreddit_id", return_value="t5_prof"), \ + patch("rdt_cli.client.RedditClient.create_post", return_value={}) as mock_post: + result = runner.invoke( + cli, ["post", "u_me", "T", "--text", "hi", "--recaptcha-token", "TOK", "--json"] + ) + assert result.exit_code == 0, result.output + _, kwargs = mock_post.call_args + assert kwargs["is_profile"] is True + + def test_requires_a_kind(self): + cred = self._cred() + with patch("rdt_cli.commands._common.get_credential", return_value=cred): + result = runner.invoke(cli, ["post", "test", "No body", "--draft"]) + assert result.exit_code == 2 + assert "exactly one" in result.output + + def test_rejects_two_kinds(self): + cred = self._cred() + with patch("rdt_cli.commands._common.get_credential", return_value=cred): + result = runner.invoke( + cli, ["post", "test", "Two", "--text", "a", "--url", "https://e.com", "--draft"] + ) + assert result.exit_code == 2 + assert "exactly one" in result.output + + def test_rejects_image_draft(self, tmp_path): + cred = self._cred() + img = tmp_path / "pic.png" + img.write_bytes(b"\x89PNG\r\n\x1a\n") + with patch("rdt_cli.commands._common.get_credential", return_value=cred): + result = runner.invoke(cli, ["post", "test", "Pic", "--image", str(img), "--draft"]) + assert result.exit_code == 2 + assert "draft" in result.output.lower() + + def test_not_logged_in(self): + with patch("rdt_cli.commands._common.get_credential", return_value=None): + result = runner.invoke(cli, ["post", "test", "Title", "--text", "hi", "--draft"]) + assert result.exit_code == 1 + + +# โ”€โ”€ Client post-creation methods (mocked) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + +class TestClientPostMethods: + def _client(self): + from rdt_cli.auth import Credential + from rdt_cli.client import RedditClient + + cred = Credential(cookies={"reddit_session": "x", "csrf_token": "tok"}) + return RedditClient(cred) + + def test_graphql_sends_operation_and_csrf(self): + captured = {} + + def fake_write(method, url, **kwargs): + captured.update(method=method, url=url, json=kwargs.get("json")) + return {"data": {"ok": True}} + + with self._client() as client: + with patch.object(client, "_write_request", side_effect=fake_write): + data = client._graphql("CreatePost", {"input": {"a": 1}}) + assert data == {"ok": True} + assert captured["url"].endswith("/svc/shreddit/graphql") + body = captured["json"] + assert body["operation"] == "CreatePost" + assert body["variables"] == {"input": {"a": 1}} + assert body["csrf_token"] == "tok" + + def test_graphql_raises_on_errors(self): + from rdt_cli.exceptions import RedditApiError + + def fake_write(*a, **k): + return {"data": None, "errors": [{"message": "boom"}]} + + with self._client() as client: + with patch.object(client, "_write_request", side_effect=fake_write): + with pytest.raises(RedditApiError, match="boom"): + client._graphql("CreatePost", {}) + + def test_resolve_subreddit_id(self): + with self._client() as client: + with patch.object( + client, "get_subreddit_about", return_value={"name": "t5_2qh1i", "display_name": "test"} + ): + assert client.resolve_subreddit_id("test") == "t5_2qh1i" + + def test_create_draft_payload(self): + with self._client() as client: + with patch.object(client, "_graphql", return_value={"createDraft": {"ok": True}}) as g: + client.create_draft("t5_x", "Title", body="hello") + op, variables = g.call_args.args + assert op == "CreateDraft" + inp = variables["input"] + assert inp["subredditId"] == "t5_x" + assert inp["title"] == "Title" + assert inp["kind"] == "MARKDOWN" + assert inp["content"] == {"markdown": "hello"} + + def test_create_post_link_payload(self): + with self._client() as client: + with patch.object(client, "_graphql", return_value={}) as g: + client.create_post("t5_x", "T", recaptcha_token="TOK", kind="link", url="https://e.com") + op, variables = g.call_args.args + assert op == "CreatePost" + inp = variables["input"] + assert inp["postType"] == "LINK" + assert inp["url"] == "https://e.com" + assert inp["recaptchaToken"] == "TOK" + assert "correlationId" in inp + + def test_create_profile_post_image_payload(self): + with self._client() as client: + with patch.object(client, "_graphql", return_value={}) as g: + client.create_post( + "t5_prof", "T", recaptcha_token="TOK", kind="image", + media_id="mid42", is_profile=True, + ) + op, variables = g.call_args.args + assert op == "CreateProfilePost" + inp = variables["input"] + assert inp["image"]["url"].endswith("/mid42") + assert inp["recaptchaToken"] == "TOK" + assert "subredditId" not in inp # profile posts omit subredditId + + def test_upload_image(self, tmp_path): + img = tmp_path / "pic.png" + img.write_bytes(b"\x89PNG\r\n\x1a\n") + lease = { + "mediaId": "media123", + "uploadLease": { + "uploadLeaseUrl": "https://s3.example/", + "uploadLeaseHeaders": [{"header": "key", "value": "abc"}], + }, + } + with self._client() as client: + with patch.object(client, "create_media_lease", return_value=lease) as mock_lease: + with patch("httpx.Client.post") as mock_s3: + mock_s3.return_value.status_code = 201 + media_id = client.upload_image(str(img)) + assert media_id == "media123" + mock_lease.assert_called_once_with("PNG") + # S3 upload sends lease fields + file, no reddit cookies + _, kwargs = mock_s3.call_args + assert kwargs["data"] == {"key": "abc"} + assert "file" in kwargs["files"] + From 88330f746c14b5ef287f0bc2624edfd28194a2c2 Mon Sep 17 00:00:00 2001 From: Nikolay Bryskin <nikicat@users.noreply.github.com> Date: Wed, 22 Jul 2026 12:43:25 +0300 Subject: [PATCH 2/8] feat: solve reCAPTCHA Enterprise via Solvecaptcha when publishing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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> --- README.md | 22 +++-- SKILL.md | 8 +- rdt_cli/captcha.py | 139 ++++++++++++++++++++++++++++++++ rdt_cli/commands/submit.py | 49 +++++++++--- rdt_cli/constants.py | 17 ++++ tests/test_cli.py | 160 ++++++++++++++++++++++++++++++++++++- 6 files changed, 371 insertions(+), 24 deletions(-) create mode 100644 rdt_cli/captcha.py diff --git a/README.md b/README.md index 21cd9fb..ca26ca3 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@ A CLI for Reddit โ€” browse feeds, read posts, search, and interact via reverse- - ๐Ÿ“ค **Export** โ€” export search results to CSV or JSON; `-o file.json` on any listing - ๐Ÿ‘ค **Users** โ€” view user profiles, post history, comment history, saved and upvoted items - โฌ†๏ธ **Interactions** โ€” upvote/downvote, save/unsave, subscribe/unsubscribe, comment (with 1.5-4s rate-limit delay) -- โœ๏ธ **Create posts** โ€” save text/link **drafts** headlessly (`rdt post --draft`); publishing is reCAPTCHA-gated and needs a browser token (`--recaptcha-token`) +- โœ๏ธ **Create posts** โ€” save text/link **drafts** headlessly (`rdt post --draft`); publish text/link/image posts with a browser reCAPTCHA token (`--recaptcha-token`) or automatic solving via solvecaptcha.com - ๐Ÿ›ก๏ธ **Anti-detection** โ€” consistent Chrome 133 fingerprint, `sec-ch-ua` alignment, Gaussian jitter, exponential backoff - ๐Ÿ“Š **Structured output** โ€” `--yaml`, `--json`, `--output FILE`, `--compact`, `--full-text` - ๐Ÿ“ฆ **Stable envelope** โ€” see [SCHEMA.md](./SCHEMA.md) for `ok/schema_version/data/error` @@ -123,18 +123,23 @@ rdt subscribe python --undo # Unsubscribe rdt comment 3 "Great post!" # Comment on result #3 # โ”€โ”€โ”€ Create posts (require login) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ -# Drafts save headlessly. Publishing is gated behind reCAPTCHA Enterprise, so it -# needs a --recaptcha-token captured from a browser (single-use, ~2 min). +# Drafts save headlessly. Publishing is gated behind reCAPTCHA Enterprise: +# either pass --recaptcha-token from a browser (single-use, ~2 min), or set +# RDT_SOLVECAPTCHA_API_KEY (or APIKEY_SOLVECAPTCHA) and a token is bought from +# solvecaptcha.com automatically right before publishing (paid, ~10-60s). rdt post python "WIP title" --text "Hello **world**" --draft # Save a text draft rdt post news "Cool read" --url https://example.com --draft # Save a link draft rdt post python "Title" --text "body" --recaptcha-token <tok> # Publish text post -rdt post pics "My cat" --image cat.jpg --recaptcha-token <tok> # Publish image post -rdt post u_yourname "On my profile" --text "hi" --recaptcha-token <tok> +rdt post pics "My cat" --image cat.jpg # Publish image (captcha auto-solved) +rdt post u_yourname "On my profile" --text "hi" # Publish to profile ``` > **Note:** Reddit drafts store text/link only โ€” not images (image drafts aren't -> supported by Reddit). And publishing any post requires a reCAPTCHA Enterprise -> token that only a browser can produce; rdt-cli never solves the captcha. +> supported by Reddit). Publishing any post requires a reCAPTCHA Enterprise +> token: capture one from a browser, or let rdt-cli buy one via +> [Solvecaptcha](https://solvecaptcha.com) โ€” set `RDT_SOLVECAPTCHA_API_KEY` or +> `APIKEY_SOLVECAPTCHA` (its 2captcha-compatible API is called directly over +> httpx, so no extra dependencies). Each solve is a paid API call (~10-60s). ## Authentication @@ -163,6 +168,8 @@ After any listing command such as `feed`, `popular`, `all`, `sub`, or `search`, | Variable | Default | Description | |----------|---------|-------------| | `OUTPUT` | `auto` | Output format: `json`, `yaml`, `rich`, or `auto` (โ†’ YAML when non-TTY) | +| `RDT_SOLVECAPTCHA_API_KEY` | โ€” | Solvecaptcha API key โ€” enables automatic reCAPTCHA solving when publishing posts | +| `APIKEY_SOLVECAPTCHA` | โ€” | Fallback for the above (solvecaptcha-python's own convention) | ## Rate Limiting & Anti-Detection @@ -224,6 +231,7 @@ rdt_cli/ โ”œโ”€โ”€ cli.py # Click entry point & command registration โ”œโ”€โ”€ client.py # Reddit API client (rate-limit, retry, anti-detection) โ”œโ”€โ”€ auth.py # Cookie authentication + TTL refresh +โ”œโ”€โ”€ captcha.py # reCAPTCHA Enterprise solving via the Solvecaptcha API โ”œโ”€โ”€ constants.py # URLs, headers, sort options โ”œโ”€โ”€ exceptions.py # Error hierarchy (6 exception types) โ”œโ”€โ”€ index_cache.py # Short-index cache for show/open commands diff --git a/SKILL.md b/SKILL.md index 4ef195a..04e2b48 100644 --- a/SKILL.md +++ b/SKILL.md @@ -130,16 +130,16 @@ Payloads live under `.data`. Exactly one of `--text` / `--url` / `--image`. Target a subreddit or a profile (`u_<name>`). Add `--json` for a structured result. -**Drafts** save headlessly. **Publishing** is gated behind reCAPTCHA Enterprise (invisible, score-based) โ€” it needs a `--recaptcha-token` captured from a browser (single-use, ~2 min). rdt-cli never solves the captcha. +**Drafts** save headlessly. **Publishing** is gated behind reCAPTCHA Enterprise (invisible, score-based). Token sources, checked in order: `--recaptcha-token` captured from a browser (single-use, ~2 min), else automatic solving via solvecaptcha.com when `RDT_SOLVECAPTCHA_API_KEY` (or `APIKEY_SOLVECAPTCHA`) is set โ€” the key's presence opts in to a paid solve (~10-60s) right before publishing. | Command | Description | Example | |---------|-------------|---------| | `rdt post <sub> <title> --text <body> --draft` | Save a text draft | `rdt post python "WIP" --text "..." --draft` | | `rdt post <sub> <title> --url <url> --draft` | Save a link draft | `rdt post news "Read" --url https://ex.com --draft` | -| `rdt post <sub> <title> --text <body> --recaptcha-token <tok>` | Publish text post | `rdt post python "Hi" --text "..." --recaptcha-token <tok>` | -| `rdt post <sub> <title> --image <file> --recaptcha-token <tok>` | Publish image post | `rdt post pics "Cat" --image cat.jpg --recaptcha-token <tok>` | +| `rdt post <sub> <title> --text <body> --recaptcha-token <tok>` | Publish text post (browser token) | `rdt post python "Hi" --text "..." --recaptcha-token <tok>` | +| `rdt post <sub> <title> --image <file>` | Publish image post (captcha auto-solved if key set) | `rdt post pics "Cat" --image cat.jpg` | -Optional flags: `--nsfw`, `--spoiler`. Notes: Reddit drafts can't store images (text/link only); `--image` requires publishing with a token. +Optional flags: `--nsfw`, `--spoiler`. Notes: Reddit drafts can't store images (text/link only); `--image` requires publishing with a token (flag or solver). ### Account diff --git a/rdt_cli/captcha.py b/rdt_cli/captcha.py new file mode 100644 index 0000000..b83083f --- /dev/null +++ b/rdt_cli/captcha.py @@ -0,0 +1,139 @@ +"""reCAPTCHA Enterprise solving via the Solvecaptcha API (solvecaptcha.com). + +Publishing a Reddit post is gated behind reCAPTCHA Enterprise (invisible, +score-based, action ``post_submit`` โ€” see rdt_cli.client.create_post). A token +can be captured from a browser, or bought from a captcha-solving service. This +module talks to the Solvecaptcha API directly โ€” the 2captcha-compatible +protocol that the ``solvecaptcha-python`` package wraps (POST /in.php, poll +/res.php) โ€” over httpx, so no extra runtime dependency is needed. + +API key resolution (first match wins, see SOLVECAPTCHA_KEY_ENV_VARS): + 1. ``RDT_SOLVECAPTCHA_API_KEY`` + 2. ``APIKEY_SOLVECAPTCHA`` (solvecaptcha-python's own convention) + +The returned token is single-use with a ~2 minute TTL โ€” submit the post +immediately after solving. +""" + +from __future__ import annotations + +import logging +import os +import time + +import httpx + +from .constants import ( + BASE_URL, + RECAPTCHA_ACTION, + RECAPTCHA_SITEKEY, + SOLVECAPTCHA_API_URL, + SOLVECAPTCHA_KEY_ENV_VARS, +) +from .exceptions import RedditApiError + +logger = logging.getLogger(__name__) + +__all__ = [ + "CaptchaSolveError", + "get_solvecaptcha_api_key", + "solve_recaptcha_token", +] + + +class CaptchaSolveError(RedditApiError): + """Raised when the captcha-solving service rejects the task or times out.""" + + def __init__(self, message: str, code: int | str | None = None): + super().__init__(f"Captcha solving failed: {message}", code=code) + + +def get_solvecaptcha_api_key() -> str | None: + """Return the Solvecaptcha API key from the environment, if configured.""" + for var in SOLVECAPTCHA_KEY_ENV_VARS: + key = os.environ.get(var, "").strip() + if key: + return key + return None + + +def solve_recaptcha_token( + api_key: str, + *, + sitekey: str = RECAPTCHA_SITEKEY, + page_url: str = f"{BASE_URL}/", + action: str = RECAPTCHA_ACTION, + min_score: float = 0.3, + timeout: float = 180.0, + polling_interval: float = 5.0, +) -> str: + """Buy a reCAPTCHA Enterprise token for Reddit from Solvecaptcha. + + Submits a score-based Enterprise task (``method=userrecaptcha``, + ``version=v3``, ``enterprise=1`` โ€” how 2captcha-style APIs model + reCAPTCHA Enterprise) and polls for the solution. ``min_score`` is the + worker's target score; scores above ~0.3 are rarely achievable. Returns + the ``g-recaptcha-response`` token (single-use, ~2 min TTL โ€” use it + immediately). Raises CaptchaSolveError on service errors or timeout. + """ + task = { + "key": api_key, + "method": "userrecaptcha", + "googlekey": sitekey, + "pageurl": page_url, + "version": "v3", + "enterprise": 1, + "action": action, + "min_score": min_score, + } + with httpx.Client(base_url=SOLVECAPTCHA_API_URL, timeout=httpx.Timeout(30.0)) as http: + captcha_id = _submit(http, task) + logger.debug( + "Solvecaptcha task %s submitted (sitekey=%s, action=%s)", + captcha_id, sitekey, action, + ) + return _poll(http, api_key, captcha_id, timeout=timeout, polling_interval=polling_interval) + + +def _submit(http: httpx.Client, task: dict) -> str: + """POST the task to in.php and return the captcha id.""" + try: + resp = http.post("/in.php", data=task) + resp.raise_for_status() + except httpx.HTTPError as exc: + raise CaptchaSolveError(f"could not reach Solvecaptcha: {exc}") from exc + body = resp.text.strip() + if body.startswith("OK|"): + return body[3:] + # ERROR_WRONG_USER_KEY, ERROR_ZERO_BALANCE, ERROR_WRONG_GOOGLEKEY, ... + raise CaptchaSolveError(f"task rejected: {body}") + + +def _poll( + http: httpx.Client, + api_key: str, + captcha_id: str, + *, + timeout: float, + polling_interval: float, +) -> str: + """Poll res.php until the token is ready; returns the token.""" + deadline = time.monotonic() + timeout + while True: + try: + resp = http.get( + "/res.php", + params={"key": api_key, "action": "get", "id": captcha_id}, + ) + resp.raise_for_status() + except httpx.HTTPError as exc: + raise CaptchaSolveError(f"result polling failed: {exc}") from exc + body = resp.text.strip() + if body.startswith("OK|"): + return body[3:] + if body == "CAPCHA_NOT_READY": + if time.monotonic() >= deadline: + raise CaptchaSolveError(f"no solution after {timeout:.0f}s") + time.sleep(polling_interval) + continue + raise CaptchaSolveError(f"service error: {body}") diff --git a/rdt_cli/commands/submit.py b/rdt_cli/commands/submit.py index 2b7ae7b..2298928 100644 --- a/rdt_cli/commands/submit.py +++ b/rdt_cli/commands/submit.py @@ -2,10 +2,15 @@ Reddit's web GraphQL (see rdt_cli.client) lets us create **drafts** headlessly, but **publishing** a post is gated behind reCAPTCHA Enterprise (invisible, -score-based, action ``post_submit``). A valid token can only be produced by a -real browser, so publishing requires the caller to pass ``--recaptcha-token`` -(obtained from DevTools, single-use, ~2 min TTL). The tool never solves the -captcha itself. Without a token, use ``--draft``. +score-based, action ``post_submit``). A valid token can come from two sources: + +1. ``--recaptcha-token`` โ€” captured from a real browser (DevTools, single-use, + ~2 min TTL), or +2. automatic solving โ€” when a Solvecaptcha API key is configured (env + ``RDT_SOLVECAPTCHA_API_KEY`` or ``APIKEY_SOLVECAPTCHA``), a token is bought + from solvecaptcha.com just before publishing (see rdt_cli.captcha). + +Without either, use ``--draft``. """ from __future__ import annotations @@ -14,6 +19,7 @@ import click +from ..captcha import get_solvecaptcha_api_key, solve_recaptcha_token from ..client import RedditClient from ..constants import BASE_URL from ..exceptions import RedditApiError @@ -56,8 +62,10 @@ def _permalink_from(data: Any) -> str | None: _RECAPTCHA_HELP = ( "Publishing requires a reCAPTCHA Enterprise token โ€” Reddit gates post " "submission with invisible, score-based reCAPTCHA (action 'post_submit'). " - "A token can only be produced by a browser (single-use, ~2 min). Pass it via " - "--recaptcha-token, or use --draft to save a draft headlessly." + "Either pass a browser-captured token via --recaptcha-token (single-use, " + "~2 min), or set a Solvecaptcha API key (env RDT_SOLVECAPTCHA_API_KEY or " + "APIKEY_SOLVECAPTCHA) to buy a token automatically. Without either, use " + "--draft to save a draft headlessly." ) @@ -75,7 +83,8 @@ def _permalink_from(data: Any) -> str | None: @click.option("--draft", is_flag=True, help="Save as a draft instead of publishing (text/link only)") @click.option( "--recaptcha-token", "recaptcha_token", default=None, - help="reCAPTCHA Enterprise token from a browser (required to publish)", + help="reCAPTCHA Enterprise token from a browser (otherwise a token is " + "bought via Solvecaptcha when an API key is configured)", ) @click.option("--nsfw", is_flag=True, help="Mark as NSFW") @click.option("--spoiler", is_flag=True, help="Mark as spoiler") @@ -98,13 +107,15 @@ def post( Provide exactly one of --text, --url, or --image. Drafts (--draft) are created headlessly. Publishing is gated behind reCAPTCHA - Enterprise, so it needs a --recaptcha-token captured from a browser. + Enterprise: pass a browser-captured --recaptcha-token, or set a Solvecaptcha + API key (env RDT_SOLVECAPTCHA_API_KEY or APIKEY_SOLVECAPTCHA) and a token is + bought automatically right before publishing. Examples: rdt post python "My title" --text "Hello **world**" --draft rdt post news "Interesting" --url https://example.com --draft rdt post pics "My cat" --image cat.jpg --recaptcha-token <token> - rdt post u_myname "On my profile" --text "hi" --recaptcha-token <token> + rdt post u_myname "On my profile" --text "hi" # solves via Solvecaptcha """ provided = [ name for name, given in @@ -119,10 +130,17 @@ def post( raise click.UsageError( "--image cannot be combined with --draft: Reddit drafts cannot store " "images (the image only attaches at publish time). Publish it with " - "--recaptcha-token, or draft text/link only." + "--recaptcha-token (or a configured Solvecaptcha API key), or draft " + "text/link only." ) + + # Token source for publishing: explicit flag wins; otherwise a Solvecaptcha + # API key must be configured (a token is bought just before publishing). + solver_api_key: str | None = None if not draft and not recaptcha_token: - raise click.UsageError(_RECAPTCHA_HELP) + solver_api_key = get_solvecaptcha_api_key() + if not solver_api_key: + raise click.UsageError(_RECAPTCHA_HELP) is_profile = subreddit.lower().startswith("u_") @@ -138,8 +156,15 @@ def post( action = "Draft saved" else: media_id = client.upload_image(image) if image is not None else None + if recaptcha_token is None: + # Solve last โ€” the token is single-use with a ~2 min TTL. + console.print( + "[dim]โณ Solving reCAPTCHA Enterprise via solvecaptcha.com " + "(paid, usually ~10-60s)โ€ฆ[/dim]" + ) + recaptcha_token = solve_recaptcha_token(solver_api_key or "") data = client.create_post( - subreddit_id, title, recaptcha_token=recaptcha_token or "", kind=kind, + subreddit_id, title, recaptcha_token=recaptcha_token, kind=kind, body=text, url=link_url, media_id=media_id, nsfw=nsfw, spoiler=spoiler, is_profile=is_profile, ) diff --git a/rdt_cli/constants.py b/rdt_cli/constants.py index 0da40e1..539eb46 100644 --- a/rdt_cli/constants.py +++ b/rdt_cli/constants.py @@ -65,6 +65,23 @@ # S3 host that serves uploaded media; the object URL is "<host>/<mediaId>". MEDIA_S3_HOST = "https://reddit-uploaded-media.s3-accelerate.amazonaws.com" +# โ”€โ”€ reCAPTCHA Enterprise (post publishing) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +# Reddit gates post publishing (CreatePost/CreateProfilePost) behind reCAPTCHA +# Enterprise: invisible, score-based. RECAPTCHA_SITEKEY is Reddit's public web +# key (embedded in every shreddit page); RECAPTCHA_ACTION comes from captured +# CreatePost traffic. A token can be captured from a browser, or bought from a +# captcha-solving service (see rdt_cli.captcha). +RECAPTCHA_SITEKEY = "6LfirrMoAAAAAHZOipvza4kpp_VtTwLNuXVwURNQ" +RECAPTCHA_ACTION = "post_submit" + +# Solvecaptcha (solvecaptcha.com) โ€” 2captcha-compatible API (in.php / res.php), +# the same protocol the solvecaptcha-python package wraps. rdt_cli.captcha +# calls it directly over httpx, so no extra runtime dependency is needed. +SOLVECAPTCHA_API_URL = "https://api.solvecaptcha.com" +# Env vars checked for the API key, in priority order. APIKEY_SOLVECAPTCHA is +# the solvecaptcha-python package's own convention. +SOLVECAPTCHA_KEY_ENV_VARS = ("RDT_SOLVECAPTCHA_API_KEY", "APIKEY_SOLVECAPTCHA") + # Image posts: allowed content types (extension โ†’ MIME). The lease token sent # to Reddit is the uppercased subtype (e.g. image/jpeg โ†’ "JPEG"); the actual S3 # upload URL and fields come from the lease response. diff --git a/tests/test_cli.py b/tests/test_cli.py index d96c3bd..008a8a0 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1022,7 +1022,8 @@ def test_link_draft(self): def test_publish_requires_recaptcha_token(self): cred = self._cred() - with patch("rdt_cli.commands._common.get_credential", return_value=cred): + with patch("rdt_cli.commands._common.get_credential", return_value=cred), \ + patch("rdt_cli.commands.submit.get_solvecaptcha_api_key", return_value=None): result = runner.invoke(cli, ["post", "test", "T", "--text", "hi"]) assert result.exit_code == 2 assert "reCAPTCHA" in result.output @@ -1043,6 +1044,60 @@ def test_text_publish_with_token(self): assert kwargs["recaptcha_token"] == "TOK" assert kwargs["is_profile"] is False + def test_publish_solves_captcha_with_api_key(self): + cred = self._cred() + with patch("rdt_cli.commands._common.get_credential", return_value=cred), \ + patch("rdt_cli.commands.submit.write_delay"), \ + patch("rdt_cli.commands.submit.get_solvecaptcha_api_key", return_value="KEY"), \ + patch("rdt_cli.commands.submit.solve_recaptcha_token", return_value="SOLVED") as mock_solve, \ + patch("rdt_cli.client.RedditClient.validate_session", return_value={}), \ + patch("rdt_cli.client.RedditClient.resolve_subreddit_id", return_value="t5_abc"), \ + patch("rdt_cli.client.RedditClient.create_post", return_value={}) as mock_post: + result = runner.invoke(cli, ["post", "test", "T", "--text", "hi", "--json"]) + assert result.exit_code == 0, result.output + mock_solve.assert_called_once_with("KEY") + _, kwargs = mock_post.call_args + assert kwargs["recaptcha_token"] == "SOLVED" + + def test_explicit_token_skips_solver(self): + cred = self._cred() + with patch("rdt_cli.commands._common.get_credential", return_value=cred), \ + patch("rdt_cli.commands.submit.write_delay"), \ + patch("rdt_cli.commands.submit.get_solvecaptcha_api_key", return_value="KEY"), \ + patch("rdt_cli.commands.submit.solve_recaptcha_token") as mock_solve, \ + patch("rdt_cli.client.RedditClient.validate_session", return_value={}), \ + patch("rdt_cli.client.RedditClient.resolve_subreddit_id", return_value="t5_abc"), \ + patch("rdt_cli.client.RedditClient.create_post", return_value={}) as mock_post: + result = runner.invoke( + cli, ["post", "test", "T", "--text", "hi", "--recaptcha-token", "TOK", "--json"] + ) + assert result.exit_code == 0, result.output + mock_solve.assert_not_called() + _, kwargs = mock_post.call_args + assert kwargs["recaptcha_token"] == "TOK" + + def test_solver_failure_is_an_error(self): + from rdt_cli.captcha import CaptchaSolveError + + cred = self._cred() + with patch("rdt_cli.commands._common.get_credential", return_value=cred), \ + patch("rdt_cli.commands.submit.write_delay"), \ + patch("rdt_cli.commands.submit.get_solvecaptcha_api_key", return_value="KEY"), \ + patch( + "rdt_cli.commands.submit.solve_recaptcha_token", + side_effect=CaptchaSolveError("ERROR_ZERO_BALANCE"), + ), \ + patch("rdt_cli.client.RedditClient.validate_session", return_value={}), \ + patch("rdt_cli.client.RedditClient.resolve_subreddit_id", return_value="t5_abc"), \ + patch("rdt_cli.client.RedditClient.create_post") as mock_post: + result = runner.invoke(cli, ["post", "test", "T", "--text", "hi", "--json"]) + assert result.exit_code == 1 + mock_post.assert_not_called() + # Structured error envelope on stdout (CliRunner also mixes in the + # stderr "Solvingโ€ฆ" status line, so assert on the payload text). + assert '"ok": false' in result.output + assert "Captcha solving failed" in result.output + def test_image_publish_with_token(self, tmp_path): cred = self._cred() img = tmp_path / "pic.png" @@ -1213,3 +1268,106 @@ def test_upload_image(self, tmp_path): assert kwargs["data"] == {"key": "abc"} assert "file" in kwargs["files"] + +# โ”€โ”€ Captcha solving via Solvecaptcha (mocked HTTP) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + +class TestCaptchaSolver: + """Test rdt_cli.captcha against a mocked Solvecaptcha API (MockTransport).""" + + def _run_solver(self, handler, **kwargs): + import httpx + + from rdt_cli.captcha import solve_recaptcha_token + + real_client = httpx.Client # capture before patching (same module object) + + def client_factory(**client_kwargs): + return real_client(transport=httpx.MockTransport(handler), **client_kwargs) + + with patch("rdt_cli.captcha.httpx.Client", new=client_factory), \ + patch("rdt_cli.captcha.time.sleep"): + return solve_recaptcha_token("KEY", **kwargs) + + def test_solve_success(self): + from urllib.parse import parse_qs + + import httpx + + polls = {"n": 0} + submitted = {} + + def handler(request): + if request.url.path == "/in.php": + submitted.update(parse_qs(request.content.decode())) + return httpx.Response(200, text="OK|task123") + polls["n"] += 1 + if polls["n"] == 1: + return httpx.Response(200, text="CAPCHA_NOT_READY") + return httpx.Response(200, text="OK|TOKEN123") + + token = self._run_solver(handler) + assert token == "TOKEN123" + assert polls["n"] == 2 + # Task submitted as a score-based Enterprise reCAPTCHA (v3 + enterprise=1) + assert submitted["key"] == ["KEY"] + assert submitted["method"] == ["userrecaptcha"] + assert submitted["googlekey"] == ["6LfirrMoAAAAAHZOipvza4kpp_VtTwLNuXVwURNQ"] + assert submitted["version"] == ["v3"] + assert submitted["enterprise"] == ["1"] + assert submitted["action"] == ["post_submit"] + assert submitted["min_score"] == ["0.3"] + + def test_submit_rejected(self): + import httpx + + from rdt_cli.captcha import CaptchaSolveError + + def handler(request): + return httpx.Response(200, text="ERROR_ZERO_BALANCE") + + with pytest.raises(CaptchaSolveError, match="ERROR_ZERO_BALANCE"): + self._run_solver(handler) + + def test_poll_service_error(self): + import httpx + + from rdt_cli.captcha import CaptchaSolveError + + def handler(request): + if request.url.path == "/in.php": + return httpx.Response(200, text="OK|task123") + return httpx.Response(200, text="ERROR_WRONG_CAPTCHA_ID") + + with pytest.raises(CaptchaSolveError, match="ERROR_WRONG_CAPTCHA_ID"): + self._run_solver(handler) + + def test_timeout(self): + import httpx + + from rdt_cli.captcha import CaptchaSolveError + + def handler(request): + if request.url.path == "/in.php": + return httpx.Response(200, text="OK|task123") + return httpx.Response(200, text="CAPCHA_NOT_READY") + + with pytest.raises(CaptchaSolveError, match="no solution"): + self._run_solver(handler, timeout=0.01, polling_interval=0.001) + + def test_api_key_env_priority(self): + import os + + from rdt_cli.captcha import get_solvecaptcha_api_key + + with patch.dict(os.environ, {}, clear=True): + assert get_solvecaptcha_api_key() is None + with patch.dict(os.environ, {"APIKEY_SOLVECAPTCHA": "pkg-key"}, clear=True): + assert get_solvecaptcha_api_key() == "pkg-key" + with patch.dict( + os.environ, + {"RDT_SOLVECAPTCHA_API_KEY": "rdt-key", "APIKEY_SOLVECAPTCHA": "pkg-key"}, + clear=True, + ): + assert get_solvecaptcha_api_key() == "rdt-key" + From a813f742829de57fd9728b6a2aa633898b28a190 Mon Sep 17 00:00:00 2001 From: Nikolay Bryskin <nikicat@users.noreply.github.com> Date: Wed, 22 Jul 2026 13:08:57 +0300 Subject: [PATCH 3/8] feat: read Solvecaptcha API key from optional config file MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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> --- README.md | 13 +++++++++ SKILL.md | 2 +- rdt_cli/captcha.py | 8 ++++-- rdt_cli/commands/submit.py | 12 ++++---- rdt_cli/config.py | 33 +++++++++++++++++++++- rdt_cli/constants.py | 2 ++ tests/test_cli.py | 58 ++++++++++++++++++++++++++++++++------ 7 files changed, 110 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index ca26ca3..083684f 100644 --- a/README.md +++ b/README.md @@ -171,6 +171,19 @@ After any listing command such as `feed`, `popular`, `all`, `sub`, or `search`, | `RDT_SOLVECAPTCHA_API_KEY` | โ€” | Solvecaptcha API key โ€” enables automatic reCAPTCHA solving when publishing posts | | `APIKEY_SOLVECAPTCHA` | โ€” | Fallback for the above (solvecaptcha-python's own convention) | +## Config File + +`~/.config/rdt-cli/config.json` (optional) โ€” settings applied when no env var overrides them: + +```json +{ + "solvecaptcha_api_key": "your-solvecaptcha-key" +} +``` + +Solvecaptcha key priority: `RDT_SOLVECAPTCHA_API_KEY` โ†’ `APIKEY_SOLVECAPTCHA` โ†’ config file. +A missing or invalid file is ignored silently. + ## Rate Limiting & Anti-Detection rdt-cli includes anti-detection measures designed to minimize risk: diff --git a/SKILL.md b/SKILL.md index 04e2b48..70eddd5 100644 --- a/SKILL.md +++ b/SKILL.md @@ -130,7 +130,7 @@ Payloads live under `.data`. Exactly one of `--text` / `--url` / `--image`. Target a subreddit or a profile (`u_<name>`). Add `--json` for a structured result. -**Drafts** save headlessly. **Publishing** is gated behind reCAPTCHA Enterprise (invisible, score-based). Token sources, checked in order: `--recaptcha-token` captured from a browser (single-use, ~2 min), else automatic solving via solvecaptcha.com when `RDT_SOLVECAPTCHA_API_KEY` (or `APIKEY_SOLVECAPTCHA`) is set โ€” the key's presence opts in to a paid solve (~10-60s) right before publishing. +**Drafts** save headlessly. **Publishing** is gated behind reCAPTCHA Enterprise (invisible, score-based). Token sources, checked in order: `--recaptcha-token` captured from a browser (single-use, ~2 min), else automatic solving via solvecaptcha.com when a Solvecaptcha API key is configured โ€” env `RDT_SOLVECAPTCHA_API_KEY` / `APIKEY_SOLVECAPTCHA`, or `solvecaptcha_api_key` in `~/.config/rdt-cli/config.json`. The key's presence opts in to a paid solve (~10-60s) right before publishing. | Command | Description | Example | |---------|-------------|---------| diff --git a/rdt_cli/captcha.py b/rdt_cli/captcha.py index b83083f..56eb85e 100644 --- a/rdt_cli/captcha.py +++ b/rdt_cli/captcha.py @@ -10,6 +10,8 @@ API key resolution (first match wins, see SOLVECAPTCHA_KEY_ENV_VARS): 1. ``RDT_SOLVECAPTCHA_API_KEY`` 2. ``APIKEY_SOLVECAPTCHA`` (solvecaptcha-python's own convention) + 3. ``solvecaptcha_api_key`` in the user config file + (``~/.config/rdt-cli/config.json``) The returned token is single-use with a ~2 minute TTL โ€” submit the post immediately after solving. @@ -23,6 +25,7 @@ import httpx +from .config import load_user_config from .constants import ( BASE_URL, RECAPTCHA_ACTION, @@ -49,12 +52,13 @@ def __init__(self, message: str, code: int | str | None = None): def get_solvecaptcha_api_key() -> str | None: - """Return the Solvecaptcha API key from the environment, if configured.""" + """Return the Solvecaptcha API key: env vars first, then the config file.""" for var in SOLVECAPTCHA_KEY_ENV_VARS: key = os.environ.get(var, "").strip() if key: return key - return None + key = str(load_user_config().get("solvecaptcha_api_key", "")).strip() + return key or None def solve_recaptcha_token( diff --git a/rdt_cli/commands/submit.py b/rdt_cli/commands/submit.py index 2298928..f9f8411 100644 --- a/rdt_cli/commands/submit.py +++ b/rdt_cli/commands/submit.py @@ -7,8 +7,9 @@ 1. ``--recaptcha-token`` โ€” captured from a real browser (DevTools, single-use, ~2 min TTL), or 2. automatic solving โ€” when a Solvecaptcha API key is configured (env - ``RDT_SOLVECAPTCHA_API_KEY`` or ``APIKEY_SOLVECAPTCHA``), a token is bought - from solvecaptcha.com just before publishing (see rdt_cli.captcha). + ``RDT_SOLVECAPTCHA_API_KEY`` / ``APIKEY_SOLVECAPTCHA``, or + ``solvecaptcha_api_key`` in ``~/.config/rdt-cli/config.json``), a token is + bought from solvecaptcha.com just before publishing (see rdt_cli.captcha). Without either, use ``--draft``. """ @@ -63,9 +64,10 @@ def _permalink_from(data: Any) -> str | None: "Publishing requires a reCAPTCHA Enterprise token โ€” Reddit gates post " "submission with invisible, score-based reCAPTCHA (action 'post_submit'). " "Either pass a browser-captured token via --recaptcha-token (single-use, " - "~2 min), or set a Solvecaptcha API key (env RDT_SOLVECAPTCHA_API_KEY or " - "APIKEY_SOLVECAPTCHA) to buy a token automatically. Without either, use " - "--draft to save a draft headlessly." + "~2 min), or configure a Solvecaptcha API key to buy one automatically " + "(env RDT_SOLVECAPTCHA_API_KEY / APIKEY_SOLVECAPTCHA, or " + "'solvecaptcha_api_key' in ~/.config/rdt-cli/config.json). Without " + "either, use --draft to save a draft headlessly." ) diff --git a/rdt_cli/config.py b/rdt_cli/config.py index 555a9be..67fcef1 100644 --- a/rdt_cli/config.py +++ b/rdt_cli/config.py @@ -1,8 +1,20 @@ -"""Runtime configuration for transport, auth, and anti-detection defaults.""" +"""Runtime configuration for transport, auth, and anti-detection defaults. + +Also hosts loading of the optional user config file +(``~/.config/rdt-cli/config.json``) โ€” plain JSON with settings like +``solvecaptcha_api_key`` that apply when no env var overrides them. +""" from __future__ import annotations +import json +import logging from dataclasses import dataclass +from typing import Any + +from .constants import USER_CONFIG_FILE + +logger = logging.getLogger(__name__) @dataclass(frozen=True) @@ -17,3 +29,22 @@ class RuntimeConfig: DEFAULT_CONFIG = RuntimeConfig() + + +def load_user_config() -> dict[str, Any]: + """Load the optional user config file (``~/.config/rdt-cli/config.json``). + + Returns an empty dict when the file is absent, unreadable, or not a JSON + object โ€” the file is entirely optional and must never break the CLI. + """ + try: + data = json.loads(USER_CONFIG_FILE.read_text(encoding="utf-8")) + except FileNotFoundError: + return {} + except (OSError, ValueError) as exc: + logger.warning("Ignoring unreadable config file %s: %s", USER_CONFIG_FILE, exc) + return {} + if not isinstance(data, dict): + logger.warning("Ignoring config file %s: top level must be a JSON object", USER_CONFIG_FILE) + return {} + return data diff --git a/rdt_cli/constants.py b/rdt_cli/constants.py index 539eb46..389035f 100644 --- a/rdt_cli/constants.py +++ b/rdt_cli/constants.py @@ -5,6 +5,8 @@ # โ”€โ”€ Config โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ CONFIG_DIR = Path.home() / ".config" / "rdt-cli" CREDENTIAL_FILE = CONFIG_DIR / "credential.json" +# Optional user settings (JSON). See rdt_cli.config.load_user_config. +USER_CONFIG_FILE = CONFIG_DIR / "config.json" # โ”€โ”€ Base URL โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ BASE_URL = "https://www.reddit.com" diff --git a/tests/test_cli.py b/tests/test_cli.py index 008a8a0..436e69a 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1360,14 +1360,54 @@ def test_api_key_env_priority(self): from rdt_cli.captcha import get_solvecaptcha_api_key - with patch.dict(os.environ, {}, clear=True): + # load_user_config stubbed out: env-only resolution, hermetic against + # any real ~/.config/rdt-cli/config.json on the dev machine. + with patch("rdt_cli.captcha.load_user_config", return_value={}): + with patch.dict(os.environ, {}, clear=True): + assert get_solvecaptcha_api_key() is None + with patch.dict(os.environ, {"APIKEY_SOLVECAPTCHA": "pkg-key"}, clear=True): + assert get_solvecaptcha_api_key() == "pkg-key" + with patch.dict( + os.environ, + {"RDT_SOLVECAPTCHA_API_KEY": "rdt-key", "APIKEY_SOLVECAPTCHA": "pkg-key"}, + clear=True, + ): + assert get_solvecaptcha_api_key() == "rdt-key" + + def test_api_key_from_config_file(self, tmp_path): + import os + + from rdt_cli.captcha import get_solvecaptcha_api_key + + cfg = tmp_path / "config.json" + cfg.write_text(json.dumps({"solvecaptcha_api_key": "file-key"})) + with patch.dict(os.environ, {}, clear=True), \ + patch("rdt_cli.config.USER_CONFIG_FILE", cfg): + assert get_solvecaptcha_api_key() == "file-key" + + def test_api_key_env_beats_config_file(self, tmp_path): + import os + + from rdt_cli.captcha import get_solvecaptcha_api_key + + cfg = tmp_path / "config.json" + cfg.write_text(json.dumps({"solvecaptcha_api_key": "file-key"})) + with patch.dict(os.environ, {"APIKEY_SOLVECAPTCHA": "env-key"}, clear=True), \ + patch("rdt_cli.config.USER_CONFIG_FILE", cfg): + assert get_solvecaptcha_api_key() == "env-key" + + def test_api_key_missing_or_invalid_config_file(self, tmp_path): + import os + + from rdt_cli.captcha import get_solvecaptcha_api_key + + with patch.dict(os.environ, {}, clear=True), \ + patch("rdt_cli.config.USER_CONFIG_FILE", tmp_path / "nope.json"): + assert get_solvecaptcha_api_key() is None + + bad = tmp_path / "config.json" + bad.write_text("{not json") + with patch.dict(os.environ, {}, clear=True), \ + patch("rdt_cli.config.USER_CONFIG_FILE", bad): assert get_solvecaptcha_api_key() is None - with patch.dict(os.environ, {"APIKEY_SOLVECAPTCHA": "pkg-key"}, clear=True): - assert get_solvecaptcha_api_key() == "pkg-key" - with patch.dict( - os.environ, - {"RDT_SOLVECAPTCHA_API_KEY": "rdt-key", "APIKEY_SOLVECAPTCHA": "pkg-key"}, - clear=True, - ): - assert get_solvecaptcha_api_key() == "rdt-key" From adb9a6a44db27177d9fa0792bdb16b23871a28d0 Mon Sep 17 00:00:00 2001 From: Nikolay Bryskin <nikicat@users.noreply.github.com> Date: Wed, 22 Jul 2026 13:59:31 +0300 Subject: [PATCH 4/8] =?UTF-8?q?feat:=20add=20--embed=20=E2=80=94=20inline?= =?UTF-8?q?=20images=20in=20text=20posts=20via=20![img]=20markers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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> --- README.md | 6 +++ SKILL.md | 3 +- rdt_cli/client.py | 16 +++++++ rdt_cli/commands/submit.py | 48 ++++++++++++++++++++- rdt_cli/constants.py | 13 ++++++ tests/test_cli.py | 87 ++++++++++++++++++++++++++++++++++++++ 6 files changed, 171 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 083684f..8549d9e 100644 --- a/README.md +++ b/README.md @@ -132,8 +132,14 @@ rdt post news "Cool read" --url https://example.com --draft # Save a link dra rdt post python "Title" --text "body" --recaptcha-token <tok> # Publish text post rdt post pics "My cat" --image cat.jpg # Publish image (captcha auto-solved) rdt post u_yourname "On my profile" --text "hi" # Publish to profile +rdt post python "Guide" --text "step 1 ![img] done" --embed step1.jpg # Inline image in a text post ``` +> **Inline images in text posts:** each `--embed FILE` uploads an image and +> replaces one `![img]` marker in `--text` (markers are substituted in order). +> The body then links the Reddit-hosted image (`https://i.redd.it/<id>.<ext>`, +> which serves unsigned โ€” `preview.redd.it` URLs don't). + > **Note:** Reddit drafts store text/link only โ€” not images (image drafts aren't > supported by Reddit). Publishing any post requires a reCAPTCHA Enterprise > token: capture one from a browser, or let rdt-cli buy one via diff --git a/SKILL.md b/SKILL.md index 70eddd5..0888435 100644 --- a/SKILL.md +++ b/SKILL.md @@ -138,8 +138,9 @@ Exactly one of `--text` / `--url` / `--image`. Target a subreddit or a profile ( | `rdt post <sub> <title> --url <url> --draft` | Save a link draft | `rdt post news "Read" --url https://ex.com --draft` | | `rdt post <sub> <title> --text <body> --recaptcha-token <tok>` | Publish text post (browser token) | `rdt post python "Hi" --text "..." --recaptcha-token <tok>` | | `rdt post <sub> <title> --image <file>` | Publish image post (captcha auto-solved if key set) | `rdt post pics "Cat" --image cat.jpg` | +| `rdt post <sub> <title> --text <body> --embed <file>` | Text post with inline image(s) | `rdt post python "Guide" --text "step 1 ![img]" --embed s1.jpg` | -Optional flags: `--nsfw`, `--spoiler`. Notes: Reddit drafts can't store images (text/link only); `--image` requires publishing with a token (flag or solver). +Optional flags: `--nsfw`, `--spoiler`. Notes: Reddit drafts can't store images (text/link only); `--image` requires publishing with a token (flag or solver). `--embed` needs one `![img]` marker per file in `--text` (substituted in order); the uploaded image is linked as `https://i.redd.it/<id>.<ext>`. ### Account diff --git a/rdt_cli/client.py b/rdt_cli/client.py index e84f981..a49d219 100644 --- a/rdt_cli/client.py +++ b/rdt_cli/client.py @@ -18,7 +18,9 @@ DEFAULT_LIMIT, GRAPHQL_URL, HOME_URL, + IMAGE_MIME_TO_EXT, IMAGE_MIME_TYPES, + MEDIA_CDN_HOST, MEDIA_S3_HOST, MORECHILDREN_URL, OP_CREATE_DRAFT, @@ -455,6 +457,20 @@ def upload_image(self, path: str) -> str: raise RedditApiError(f"Image upload failed (HTTP {resp.status_code})") return media_id + def upload_image_as_embed(self, path: str) -> str: + """Upload an image and return its CDN URL for inline embeds in self-posts. + + A self-post can't hold media directly, but its markdown can reference + Reddit-hosted images: ``https://i.redd.it/<mediaId>.<ext>`` serves + unsigned (unlike ``preview.redd.it`` URLs, whose ``s=`` param is a + signature). New Reddit is expected to render such links inline, as it + does for editor-uploaded embeds (live verification pending). + """ + media_id = self.upload_image(path) + content_type, _ = mimetypes.guess_type(Path(path).name) + ext = IMAGE_MIME_TO_EXT.get(content_type or "", "jpg") + return f"{MEDIA_CDN_HOST}/{media_id}.{ext}" + def create_post( self, subreddit_id: str, diff --git a/rdt_cli/commands/submit.py b/rdt_cli/commands/submit.py index f9f8411..1e0d741 100644 --- a/rdt_cli/commands/submit.py +++ b/rdt_cli/commands/submit.py @@ -16,6 +16,7 @@ from __future__ import annotations +from pathlib import Path from typing import Any import click @@ -60,6 +61,20 @@ def _permalink_from(data: Any) -> str | None: return permalink +def _embed_images(text: str, embeds: tuple[str, ...], urls: list[str]) -> str: + """Replace each ``![img]`` marker with a markdown link to an uploaded image. + + Markers are substituted in order: the Nth ``![img]`` becomes + ``[<file stem>](<cdn url>)`` for the Nth --embed file. + """ + parts = text.split("![img]") + out = [parts[0]] + for embed, url, tail in zip(embeds, urls, parts[1:], strict=True): + out.append(f"[{Path(embed).stem}]({url})") + out.append(tail) + return "".join(out) + + _RECAPTCHA_HELP = ( "Publishing requires a reCAPTCHA Enterprise token โ€” Reddit gates post " "submission with invisible, score-based reCAPTCHA (action 'post_submit'). " @@ -83,6 +98,12 @@ def _permalink_from(data: Any) -> str | None: help="Image file to post (publish only; drafts can't store images)", ) @click.option("--draft", is_flag=True, help="Save as a draft instead of publishing (text/link only)") +@click.option( + "--embed", "embeds", + multiple=True, + type=click.Path(exists=True, dir_okay=False), + help="Image to embed inline in the --text body; one per ![img] marker, in order", +) @click.option( "--recaptcha-token", "recaptcha_token", default=None, help="reCAPTCHA Enterprise token from a browser (otherwise a token is " @@ -98,6 +119,7 @@ def post( link_url: str | None, image: str | None, draft: bool, + embeds: tuple[str, ...], recaptcha_token: str | None, nsfw: bool, spoiler: bool, @@ -106,7 +128,9 @@ def post( ) -> None: """Create a post in a subreddit or profile (u_<name>): text, link, or image. - Provide exactly one of --text, --url, or --image. + Provide exactly one of --text, --url, or --image. With --text, --embed + uploads an image and substitutes it for an ![img] marker in the body + (one marker per --embed, in order), giving inline images in a text post. Drafts (--draft) are created headlessly. Publishing is gated behind reCAPTCHA Enterprise: pass a browser-captured --recaptcha-token, or set a Solvecaptcha @@ -117,6 +141,7 @@ def post( rdt post python "My title" --text "Hello **world**" --draft rdt post news "Interesting" --url https://example.com --draft rdt post pics "My cat" --image cat.jpg --recaptcha-token <token> + rdt post python "Guide" --text "step 1 ![img] done" --embed step1.jpg rdt post u_myname "On my profile" --text "hi" # solves via Solvecaptcha """ provided = [ @@ -128,6 +153,23 @@ def post( raise click.UsageError("Provide exactly one of --text, --url, or --image.") kind = {"--text": "self", "--url": "link", "--image": "image"}[provided[0]] + if embeds and kind != "self": + raise click.UsageError( + "--embed only works with --text (inline images live in the self-post body)." + ) + markers = text.count("![img]") if text else 0 + if embeds and markers != len(embeds): + raise click.UsageError( + f"--embed was given {len(embeds)} image(s) but the --text body has " + f"{markers} ![img] marker(s). Place one ![img] marker per embedded " + "image, in order." + ) + if markers and not embeds: + raise click.UsageError( + f"The --text body has {markers} ![img] marker(s) but no --embed was " + "given. Pass one --embed <file> per marker, or remove the markers." + ) + if draft and kind == "image": raise click.UsageError( "--image cannot be combined with --draft: Reddit drafts cannot store " @@ -151,6 +193,10 @@ def post( with RedditClient(cred) as client: client.validate_session() subreddit_id = client.resolve_subreddit_id(subreddit) + if embeds: + console.print(f"[dim]โณ Uploading {len(embeds)} embedded image(s)โ€ฆ[/dim]") + urls = [client.upload_image_as_embed(embed) for embed in embeds] + text = _embed_images(text or "", embeds, urls) if draft: data = client.create_draft( subreddit_id, title, body=text, url=link_url, nsfw=nsfw, spoiler=spoiler, diff --git a/rdt_cli/constants.py b/rdt_cli/constants.py index 389035f..42b032b 100644 --- a/rdt_cli/constants.py +++ b/rdt_cli/constants.py @@ -67,6 +67,11 @@ # S3 host that serves uploaded media; the object URL is "<host>/<mediaId>". MEDIA_S3_HOST = "https://reddit-uploaded-media.s3-accelerate.amazonaws.com" +# CDN host that serves uploaded media as "<host>/<mediaId>.<ext>" WITHOUT a +# signature โ€” used for inline image embeds in self-post markdown. (Unsigned +# preview.redd.it URLs return 403: the "s=" query param there is a signature.) +MEDIA_CDN_HOST = "https://i.redd.it" + # โ”€โ”€ reCAPTCHA Enterprise (post publishing) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ # Reddit gates post publishing (CreatePost/CreateProfilePost) behind reCAPTCHA # Enterprise: invisible, score-based. RECAPTCHA_SITEKEY is Reddit's public web @@ -94,6 +99,14 @@ "image/webp", } +# MIME โ†’ file extension for media URLs (Reddit uses "jpg" for JPEGs). +IMAGE_MIME_TO_EXT = { + "image/jpeg": "jpg", + "image/png": "png", + "image/gif": "gif", + "image/webp": "webp", +} + # โ”€โ”€ Request Headers (Chrome 133, macOS) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ HEADERS = { "User-Agent": ( diff --git a/tests/test_cli.py b/tests/test_cli.py index 436e69a..4b53c89 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1117,6 +1117,83 @@ def test_image_publish_with_token(self, tmp_path): assert kwargs["kind"] == "image" assert kwargs["media_id"] == "media123" + def test_embed_requires_text_kind(self, tmp_path): + cred = self._cred() + img = tmp_path / "pic.jpg" + img.write_bytes(b"\xff\xd8\xff") + with patch("rdt_cli.commands._common.get_credential", return_value=cred): + for kind_args in (["--url", "https://e.com"], ["--image", str(img)]): + result = runner.invoke( + cli, ["post", "test", "T", *kind_args, "--embed", str(img), "--draft"] + ) + assert result.exit_code == 2 + assert "--embed only works with --text" in result.output + + def test_embed_marker_count_mismatch(self, tmp_path): + cred = self._cred() + img = tmp_path / "pic.jpg" + img.write_bytes(b"\xff\xd8\xff") + with patch("rdt_cli.commands._common.get_credential", return_value=cred): + result = runner.invoke( + cli, ["post", "test", "T", "--text", "no markers", "--embed", str(img), "--draft"] + ) + assert result.exit_code == 2 + assert "![img]" in result.output + + def test_embed_marker_without_embed(self): + cred = self._cred() + with patch("rdt_cli.commands._common.get_credential", return_value=cred): + result = runner.invoke( + cli, ["post", "test", "T", "--text", "look ![img] here", "--draft"] + ) + assert result.exit_code == 2 + assert "no --embed" in result.output + + def test_embed_publish_substitutes_markers(self, tmp_path): + cred = self._cred() + img = tmp_path / "cat pic.jpg" + img.write_bytes(b"\xff\xd8\xff") + with patch("rdt_cli.commands._common.get_credential", return_value=cred), \ + patch("rdt_cli.commands.submit.write_delay"), \ + patch("rdt_cli.client.RedditClient.validate_session", return_value={}), \ + patch("rdt_cli.client.RedditClient.resolve_subreddit_id", return_value="t5_abc"), \ + patch( + "rdt_cli.client.RedditClient.upload_image_as_embed", + return_value="https://i.redd.it/mid1.jpg", + ) as mock_up, \ + patch("rdt_cli.client.RedditClient.create_post", return_value={}) as mock_post: + result = runner.invoke( + cli, + ["post", "test", "T", "--text", "before ![img] after", + "--embed", str(img), "--recaptcha-token", "TOK", "--json"], + ) + assert result.exit_code == 0, result.output + mock_up.assert_called_once_with(str(img)) + _, kwargs = mock_post.call_args + assert kwargs["kind"] == "self" + assert kwargs["body"] == "before [cat pic](https://i.redd.it/mid1.jpg) after" + assert "![img]" not in kwargs["body"] + + def test_embed_draft_substitutes_markers(self, tmp_path): + cred = self._cred() + img = tmp_path / "pic.png" + img.write_bytes(b"\x89PNG\r\n\x1a\n") + with patch("rdt_cli.commands._common.get_credential", return_value=cred), \ + patch("rdt_cli.commands.submit.write_delay"), \ + patch("rdt_cli.client.RedditClient.validate_session", return_value={}), \ + patch("rdt_cli.client.RedditClient.resolve_subreddit_id", return_value="t5_abc"), \ + patch( + "rdt_cli.client.RedditClient.upload_image_as_embed", + return_value="https://i.redd.it/mid2.png", + ), \ + patch("rdt_cli.client.RedditClient.create_draft", return_value={}) as mock_draft: + result = runner.invoke( + cli, ["post", "test", "T", "--text", "see ![img]", "--embed", str(img), "--draft", "--json"] + ) + assert result.exit_code == 0, result.output + _, kwargs = mock_draft.call_args + assert kwargs["body"] == "see [pic](https://i.redd.it/mid2.png)" + def test_profile_publish_uses_profile_flag(self): cred = self._cred() with patch("rdt_cli.commands._common.get_credential", return_value=cred), \ @@ -1268,6 +1345,16 @@ def test_upload_image(self, tmp_path): assert kwargs["data"] == {"key": "abc"} assert "file" in kwargs["files"] + def test_upload_image_as_embed(self, tmp_path): + for name, ext in (("pic.jpg", "jpg"), ("pic.jpeg", "jpg"), ("pic.png", "png"), ("pic.webp", "webp")): + img = tmp_path / name + img.write_bytes(b"\x00") + with self._client() as client: + with patch.object(client, "upload_image", return_value="mid42") as mock_up: + url = client.upload_image_as_embed(str(img)) + assert url == f"https://i.redd.it/mid42.{ext}" + mock_up.assert_called_once_with(str(img)) + # โ”€โ”€ Captcha solving via Solvecaptcha (mocked HTTP) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ From c00379ea3b02513eb5399d774ff618d7ee374f77 Mon Sep 17 00:00:00 2001 From: Nikolay Bryskin <nikicat@users.noreply.github.com> Date: Wed, 22 Jul 2026 15:59:01 +0300 Subject: [PATCH 5/8] feat: true inline & attached images in text posts; fix community publish - --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> --- README.md | 27 +++- SKILL.md | 3 +- rdt_cli/client.py | 66 +++++---- rdt_cli/commands/submit.py | 47 +++++-- rdt_cli/richtext.py | 59 ++++++++ rdt_cli/transports.py | 128 ++++++++++------- tests/test_cli.py | 273 +++++++++++++++++++++++++++++++++++-- 7 files changed, 508 insertions(+), 95 deletions(-) create mode 100644 rdt_cli/richtext.py diff --git a/README.md b/README.md index 8549d9e..7f36125 100644 --- a/README.md +++ b/README.md @@ -133,12 +133,34 @@ rdt post python "Title" --text "body" --recaptcha-token <tok> # Publish text po rdt post pics "My cat" --image cat.jpg # Publish image (captcha auto-solved) rdt post u_yourname "On my profile" --text "hi" # Publish to profile rdt post python "Guide" --text "step 1 ![img] done" --embed step1.jpg # Inline image in a text post +rdt post python "Report" --text "summary **md**" --image chart.png # Text post + attached image ``` +> **Attached image in a text post:** `--text` + `--image` publishes a markdown +> text post with the image attached (`image.url` in the mutation input): it +> renders after the body, uncaptioned. The body stays markdown, so formatting +> works โ€” markdown just can't *inline* images; for that use `--embed`. +> +> **Feed visibility trade-off (live-verified):** an attached image is the +> post's preview media, so it shows on feed/list cards too. Inline (`--embed`) +> images are body content only โ€” feed cards show just the text preview, and +> the images appear only once the post is opened. Pick `--image` for feed +> visibility, `--embed` for placement and captions. Combining `--embed` with +> `--image` gives both (live-verified): the feed card shows the attached image +> (inline embeds stay feed-hidden), and the opened post shows the inline +> embeds in place *plus* the attachment after the body โ€” so attach a distinct +> image unless you want it appearing twice in the post. + > **Inline images in text posts:** each `--embed FILE` uploads an image and > replaces one `![img]` marker in `--text` (markers are substituted in order). -> The body then links the Reddit-hosted image (`https://i.redd.it/<id>.<ext>`, -> which serves unsigned โ€” `preview.redd.it` URLs don't). +> On publish, the body is sent editor-style as RTJSON (`content.richText`, +> *instead of* markdown โ€” Reddit stores the RTJSON canonically and derives the +> markdown export from it): New Reddit renders each marker as a real inline +> image block (caption = file name). The RTJSON body is plain text, so keep +> `--text` unformatted when embedding โ€” markdown syntax would show literally. +> Drafts are markdown-only, so embeds there stay links to the Reddit-hosted +> image (`https://i.redd.it/<id>.<ext>`, which serves unsigned โ€” +> `preview.redd.it` URLs don't). > **Note:** Reddit drafts store text/link only โ€” not images (image drafts aren't > supported by Reddit). Publishing any post requires a reCAPTCHA Enterprise @@ -254,6 +276,7 @@ rdt_cli/ โ”œโ”€โ”€ constants.py # URLs, headers, sort options โ”œโ”€โ”€ exceptions.py # Error hierarchy (6 exception types) โ”œโ”€โ”€ index_cache.py # Short-index cache for show/open commands +โ”œโ”€โ”€ richtext.py # RTJSON document building (inline images in self-posts) โ””โ”€โ”€ commands/ โ”œโ”€โ”€ _common.py # Shared helpers (envelope, output routing, formatters) โ”œโ”€โ”€ auth.py # login, logout, status, whoami diff --git a/SKILL.md b/SKILL.md index 0888435..3092f40 100644 --- a/SKILL.md +++ b/SKILL.md @@ -139,8 +139,9 @@ Exactly one of `--text` / `--url` / `--image`. Target a subreddit or a profile ( | `rdt post <sub> <title> --text <body> --recaptcha-token <tok>` | Publish text post (browser token) | `rdt post python "Hi" --text "..." --recaptcha-token <tok>` | | `rdt post <sub> <title> --image <file>` | Publish image post (captcha auto-solved if key set) | `rdt post pics "Cat" --image cat.jpg` | | `rdt post <sub> <title> --text <body> --embed <file>` | Text post with inline image(s) | `rdt post python "Guide" --text "step 1 ![img]" --embed s1.jpg` | +| `rdt post <sub> <title> --text <body> --image <file>` | Text post + attached image (after body, no caption) | `rdt post python "Report" --text "summary" --image chart.png` | -Optional flags: `--nsfw`, `--spoiler`. Notes: Reddit drafts can't store images (text/link only); `--image` requires publishing with a token (flag or solver). `--embed` needs one `![img]` marker per file in `--text` (substituted in order); the uploaded image is linked as `https://i.redd.it/<id>.<ext>`. +Optional flags: `--nsfw`, `--spoiler`. Notes: Reddit drafts can't store images (text/link only); `--image` requires publishing with a token (flag or solver); `--embed` may combine with `--image`: the attachment becomes the feed-card image and also renders after the body in the opened post (inline embeds render in place there) โ€” attach a distinct image to avoid an in-post duplicate. `--embed` needs one `![img]` marker per file in `--text` (substituted in order). On publish with embeds the body goes out as RTJSON (`content.richText`, replacing markdown; without embeds it stays `content.markdown`): New Reddit renders true inline image blocks with captions. Keep `--text` plain when embedding (markdown formatting isn't translated to RTJSON and shows literally). Drafts are markdown-only โ€” embeds stay `https://i.redd.it/<id>.<ext>` links there. Feed visibility: `--embed` images show only inside the opened post (feed cards show text only); an attached `--image` also shows on feed/list cards โ€” choose by whether feed visibility or inline placement/captions matters more. ### Account diff --git a/rdt_cli/client.py b/rdt_cli/client.py index a49d219..500eba2 100644 --- a/rdt_cli/client.py +++ b/rdt_cli/client.py @@ -457,23 +457,22 @@ def upload_image(self, path: str) -> str: raise RedditApiError(f"Image upload failed (HTTP {resp.status_code})") return media_id - def upload_image_as_embed(self, path: str) -> str: - """Upload an image and return its CDN URL for inline embeds in self-posts. - - A self-post can't hold media directly, but its markdown can reference - Reddit-hosted images: ``https://i.redd.it/<mediaId>.<ext>`` serves - unsigned (unlike ``preview.redd.it`` URLs, whose ``s=`` param is a - signature). New Reddit is expected to render such links inline, as it - does for editor-uploaded embeds (live verification pending). + def upload_image_as_embed(self, path: str) -> tuple[str, str]: + """Upload an image for embedding in a self-post body; return (media_id, cdn_url). + + The media id goes into an RTJSON ``img`` node (renders as a real inline + image on New Reddit); the CDN URL is the markdown fallback link target: + ``https://i.redd.it/<mediaId>.<ext>`` serves unsigned (unlike + ``preview.redd.it`` URLs, whose ``s=`` param is a signature). """ media_id = self.upload_image(path) content_type, _ = mimetypes.guess_type(Path(path).name) ext = IMAGE_MIME_TO_EXT.get(content_type or "", "jpg") - return f"{MEDIA_CDN_HOST}/{media_id}.{ext}" + return media_id, f"{MEDIA_CDN_HOST}/{media_id}.{ext}" def create_post( self, - subreddit_id: str, + subreddit_name: str, title: str, *, recaptcha_token: str, @@ -481,6 +480,7 @@ def create_post( body: str | None = None, url: str | None = None, media_id: str | None = None, + rich_text: str | None = None, nsfw: bool = False, spoiler: bool = False, is_profile: bool = False, @@ -491,32 +491,50 @@ def create_post( score-based, action ``post_submit``). ``recaptcha_token`` MUST be a fresh token obtained from a browser (single-use, ~2 min TTL) โ€” it cannot be produced headlessly, so this call fails without one. Field shapes come - from captured live requests + ``ValidateCreatePostInput``: - selfโ†’``content.markdown``, linkโ†’``url``, imageโ†’``gallery.items[].mediaId`` - (subreddit) or ``image.url`` (profile). + from captured live requests (deviating from them 500s): + the subreddit is addressed by **name** (``subredditName`` โ€” not the + ``t5_`` id, which drafts use), there is **no** ``postType`` field, and + ``isCommercialCommunication``/``targetLanguage`` are always sent. + Kind-specific fields: selfโ†’``content.markdown``|``content.richText``, + linkโ†’``url``, imageโ†’``gallery.items[].mediaId`` (subreddit) or + ``image.url`` (profile). A self-post may also carry ``media_id``: the + image is attached via ``image.url`` (both targets โ€” captured subreddit + traffic attaches this way too, not via gallery) and renders after the + body, uncaptioned. + + For self-posts, ``rich_text`` (stringified RTJSON, see rdt_cli.richtext) + replaces the markdown: ``content`` carries exactly one representation, + and the fancy-pants editor always submits ``content.richText`` alone + (captured live requests never pair it with markdown). Reddit stores the + RTJSON canonically and derives the markdown export from it, so ``body`` + is ignored when ``rich_text`` is given. RTJSON is the only way to get + true inline image blocks โ€” a markdown body renders image links as links. """ + if kind == "self": + content = {"richText": rich_text} if rich_text else self._markdown_content(body) + else: + content = {} inp: dict[str, Any] = { "title": title, "isNsfw": bool(nsfw), "isSpoiler": bool(spoiler), - "content": self._markdown_content(body) if kind == "self" else {}, + "content": content, + "isCommercialCommunication": False, + "targetLanguage": "", "recaptchaToken": recaptcha_token, "correlationId": str(uuid.uuid4()), } + if kind == "self" and media_id: + inp["image"] = {"url": f"{MEDIA_S3_HOST}/{media_id}"} + elif kind == "link": + inp["url"] = url if is_profile: - inp["isCommercialCommunication"] = False - inp["targetLanguage"] = "" - if kind == "link": - inp["url"] = url - elif kind == "image": + if kind == "image": inp["image"] = {"url": f"{MEDIA_S3_HOST}/{media_id}"} return self._graphql(OP_CREATE_PROFILE_POST, {"input": inp}) - inp["subredditId"] = subreddit_id - inp["postType"] = {"self": "TEXT", "link": "LINK", "image": "IMAGE"}[kind] - if kind == "link": - inp["url"] = url - elif kind == "image": + inp["subredditName"] = subreddit_name.removeprefix("r/") + if kind == "image": inp["gallery"] = {"items": [{"mediaId": media_id}]} return self._graphql(OP_CREATE_POST, {"input": inp}) diff --git a/rdt_cli/commands/submit.py b/rdt_cli/commands/submit.py index 1e0d741..15b330c 100644 --- a/rdt_cli/commands/submit.py +++ b/rdt_cli/commands/submit.py @@ -25,6 +25,7 @@ from ..client import RedditClient from ..constants import BASE_URL from ..exceptions import RedditApiError +from ..richtext import build_rtjson from ._common import ( console, exit_for_error, @@ -95,7 +96,8 @@ def _embed_images(text: str, embeds: tuple[str, ...], urls: list[str]) -> str: "--image", "image", type=click.Path(exists=True, dir_okay=False), default=None, - help="Image file to post (publish only; drafts can't store images)", + help="Image file to post โ€” alone: an image post; with --text: attached " + "after the body (publish only; drafts can't store images)", ) @click.option("--draft", is_flag=True, help="Save as a draft instead of publishing (text/link only)") @click.option( @@ -128,9 +130,13 @@ def post( ) -> None: """Create a post in a subreddit or profile (u_<name>): text, link, or image. - Provide exactly one of --text, --url, or --image. With --text, --embed - uploads an image and substitutes it for an ![img] marker in the body - (one marker per --embed, in order), giving inline images in a text post. + Provide --text, --url, or --image; --text plus --image is also allowed + (the image is attached to the text post, shown after the body, no + caption). With --text, --embed uploads an image and substitutes it for an + ![img] marker in the body (one marker per --embed, in order), giving true + inline images in a text post. --embed may be combined with --image: the + attachment then also provides the feed-card preview (inline embeds alone + never show in feeds; the attachment additionally renders after the body). Drafts (--draft) are created headlessly. Publishing is gated behind reCAPTCHA Enterprise: pass a browser-captured --recaptcha-token, or set a Solvecaptcha @@ -149,9 +155,12 @@ def post( (("--text", text is not None), ("--url", link_url is not None), ("--image", image is not None)) if given ] - if len(provided) != 1: - raise click.UsageError("Provide exactly one of --text, --url, or --image.") - kind = {"--text": "self", "--url": "link", "--image": "image"}[provided[0]] + if not provided or (len(provided) > 1 and provided != ["--text", "--image"]): + raise click.UsageError( + "Provide --text, --url, or --image. Only --text --image may be " + "combined: a text post with the image attached after the body." + ) + kind = "self" if text is not None else {"--url": "link", "--image": "image"}[provided[0]] if embeds and kind != "self": raise click.UsageError( @@ -170,7 +179,7 @@ def post( "given. Pass one --embed <file> per marker, or remove the markers." ) - if draft and kind == "image": + if draft and image is not None: raise click.UsageError( "--image cannot be combined with --draft: Reddit drafts cannot store " "images (the image only attaches at publish time). Publish it with " @@ -192,12 +201,22 @@ def post( try: with RedditClient(cred) as client: client.validate_session() - subreddit_id = client.resolve_subreddit_id(subreddit) + rich_text: str | None = None if embeds: console.print(f"[dim]โณ Uploading {len(embeds)} embedded image(s)โ€ฆ[/dim]") - urls = [client.upload_image_as_embed(embed) for embed in embeds] - text = _embed_images(text or "", embeds, urls) + uploaded = [client.upload_image_as_embed(embed) for embed in embeds] + # Two body representations: publishing sends the RTJSON (true + # inline image blocks); drafts are markdown-only, so they get + # the marker-substituted CDN-link body instead. + rich_text = build_rtjson( + text or "", + [(media_id, Path(embed).stem) for (media_id, _), embed in zip(uploaded, embeds, strict=True)], + ) + text = _embed_images(text or "", embeds, [url for _, url in uploaded]) if draft: + # Drafts address the subreddit by t5_ id (confirmed live); + # publishing addresses it by name, as the editor does. + subreddit_id = client.resolve_subreddit_id(subreddit) data = client.create_draft( subreddit_id, title, body=text, url=link_url, nsfw=nsfw, spoiler=spoiler, ) @@ -212,9 +231,9 @@ def post( ) recaptcha_token = solve_recaptcha_token(solver_api_key or "") data = client.create_post( - subreddit_id, title, recaptcha_token=recaptcha_token, kind=kind, - body=text, url=link_url, media_id=media_id, nsfw=nsfw, spoiler=spoiler, - is_profile=is_profile, + subreddit, title, recaptcha_token=recaptcha_token, kind=kind, + body=text, url=link_url, media_id=media_id, rich_text=rich_text, + nsfw=nsfw, spoiler=spoiler, is_profile=is_profile, ) action = "Posted" write_delay() diff --git a/rdt_cli/richtext.py b/rdt_cli/richtext.py new file mode 100644 index 0000000..69e126b --- /dev/null +++ b/rdt_cli/richtext.py @@ -0,0 +1,59 @@ +"""Reddit RTJSON (rich text) document building for self-post bodies. + +New Reddit stores post/comment bodies canonically as RTJSON ("Lexical"-style +rich text) and merely *exports* markdown. The fancy-pants editor submits +``content.richText`` (a stringified JSON document) to the shreddit GraphQL +mutations; a markdown-only body renders ``[text](https://i.redd.it/<id>.<ext>)`` +as a plain link, while an ``img`` RTJSON node renders as a real inline image +and links the media into the post's ``media_metadata``. + +Document model (matches editor output and served ``richtext_json`` blobs): +top-level blocks in ``document`` โ€” paragraphs +(``{"e": "par", "c": [{"e": "text", "t": ...}]}``) and image blocks +(``{"e": "img", "id": "<media_id>", "c": "<caption>"}``, caption optional). +""" + +from __future__ import annotations + +import json + + +def _paragraph(line: str) -> dict: + return {"e": "par", "c": [{"e": "text", "t": line}]} + + +def build_rtjson(text: str, media: list[tuple[str, str]]) -> str: + """Build a stringified RTJSON document from a marker-bearing body. + + ``text`` is the markdown body still containing one ``![img]`` marker per + image; ``media`` holds ``(media_id, caption)`` per marker, in order + (``len(media)`` must equal the marker count). Text is split at the markers + and each ``![img]`` becomes a top-level ``img`` block between paragraphs โ€” + the same shape the fancy-pants editor produces when an image is dropped + into a body. Non-empty lines within a text segment become separate + paragraphs; blank lines are dropped (editor documents never contain empty + ``par`` nodes โ€” paragraphs are visually separated regardless). + + Note: inline markdown formatting (bold, links, โ€ฆ) inside the text is NOT + translated to RTJSON format ranges โ€” segments are emitted as plain text. + When a post is submitted as RTJSON, Reddit derives the markdown export + from it, so formatting written in the body will show literally. + """ + parts = text.split("![img]") + document: list[dict] = [] + + def add_text(segment: str) -> None: + for line in segment.split("\n"): + if line: # markers at edges / blank lines contribute no paragraph + document.append(_paragraph(line)) + + add_text(parts[0]) + for (media_id, caption), tail in zip(media, parts[1:], strict=True): + node: dict = {"e": "img", "id": media_id} + if caption: + node["c"] = caption + document.append(node) + add_text(tail) + if not document: + document.append(_paragraph("")) + return json.dumps({"document": document}, separators=(",", ":")) diff --git a/rdt_cli/transports.py b/rdt_cli/transports.py index 76a3562..1a32b68 100644 --- a/rdt_cli/transports.py +++ b/rdt_cli/transports.py @@ -46,6 +46,11 @@ def build_ssl_context() -> ssl.SSLContext: class BaseTransport: """Shared retry, throttling, and cookie management.""" + # WriteTransport opts out: mutations aren't idempotent (a 500 may follow a + # partial success) and recaptcha tokens are single-use, so a retry can at + # best fail again and at worst double-post. + retry_server_errors = True + def __init__( self, session: SessionState, @@ -102,61 +107,90 @@ def _merge_response_cookies(self, resp: httpx.Response) -> None: self.session.cookies[name] = value self.session.refresh_capabilities() + def _send(self, method: str, url: str, **kwargs: Any) -> httpx.Response: + """One attempt: send, absorb Set-Cookie into the session, count, log.""" + t0 = time.time() + resp = self.client.request(method, url, **kwargs) + self._merge_response_cookies(resp) + self._request_count += 1 + self._last_request_time = time.time() + logger.info( + "[#%d] %s %s -> %d (%.2fs)", + self._request_count, + method, + url[:80], + resp.status_code, + time.time() - t0, + ) + return resp + + @staticmethod + def _backoff(attempt: int, reason: str) -> None: + wait = (2**attempt) + random.uniform(0, 1) + logger.warning("%s, retrying in %.1fs", reason, wait) + time.sleep(wait) + + @staticmethod + def _terminal(resp: httpx.Response) -> Any: + """Turn a non-retryable response into parsed JSON or a typed error.""" + if resp.status_code == 401: + raise SessionExpiredError() + if resp.status_code == 403: + raise ForbiddenError() + if resp.status_code == 404: + raise NotFoundError() + resp.raise_for_status() + + text = resp.text + if text.strip().startswith("<"): + raise RedditApiError("Received HTML instead of JSON (possible auth redirect)") + if not text.strip(): + return {} + return resp.json() + def request(self, method: str, url: str, **kwargs: Any) -> Any: + """Send with retry policy: 429 honors Retry-After; 5xx backs off + exponentially (skipped when ``retry_server_errors`` is off) and the + final error body is surfaced; network errors back off; other statuses + resolve immediately via :meth:`_terminal`. + """ self._rate_limit_delay() last_exc: Exception | None = None + last_error_resp: httpx.Response | None = None for attempt in range(self._max_retries): - t0 = time.time() + final = attempt + 1 >= self._max_retries try: - resp = self.client.request(method, url, **kwargs) - elapsed = time.time() - t0 - self._merge_response_cookies(resp) - self._request_count += 1 - self._last_request_time = time.time() - logger.info( - "[#%d] %s %s -> %d (%.2fs)", - self._request_count, - method, - url[:80], - resp.status_code, - elapsed, - ) - - if resp.status_code == 429: - retry_after = float(resp.headers.get("Retry-After", 5)) - if attempt + 1 >= self._max_retries: - raise RateLimitError(retry_after=retry_after) - time.sleep(retry_after) - continue - - if resp.status_code in (500, 502, 503, 504): - wait = (2**attempt) + random.uniform(0, 1) - logger.warning("HTTP %d, retrying in %.1fs", resp.status_code, wait) - time.sleep(wait) - continue - - if resp.status_code == 401: - raise SessionExpiredError() - if resp.status_code == 403: - raise ForbiddenError() - if resp.status_code == 404: - raise NotFoundError() - - resp.raise_for_status() - - text = resp.text - if text.strip().startswith("<"): - raise RedditApiError("Received HTML instead of JSON (possible auth redirect)") - if not text.strip(): - return {} - return resp.json() + resp = self._send(method, url, **kwargs) except (httpx.TimeoutException, httpx.NetworkError) as exc: last_exc = exc - wait = (2**attempt) + random.uniform(0, 1) - logger.warning("Network error: %s, retrying in %.1fs", exc, wait) - time.sleep(wait) + if not final: + self._backoff(attempt, f"Network error: {exc}") + continue + + if resp.status_code == 429: + retry_after = float(resp.headers.get("Retry-After", 5)) + if final: + raise RateLimitError(retry_after=retry_after) + time.sleep(retry_after) + continue + + if resp.status_code in (500, 502, 503, 504): + last_error_resp = resp + if not self.retry_server_errors or final: + break # raise below with the body, no further attempts + self._backoff(attempt, f"HTTP {resp.status_code}") + continue + return self._terminal(resp) + + if last_error_resp is not None: + # Surface the server's error body โ€” it may carry the actual + # failure reason as JSON. + detail = " ".join(last_error_resp.text.split())[:500] + raise RedditApiError( + f"HTTP {last_error_resp.status_code}" + (f": {detail}" if detail else "") + ) if last_exc: raise RedditApiError(f"Request failed after {self._max_retries} retries: {last_exc}") from last_exc raise RedditApiError(f"Request failed after {self._max_retries} retries") @@ -172,6 +206,8 @@ def default_headers(self) -> dict[str, str]: class WriteTransport(BaseTransport): """Transport for state-changing authenticated requests.""" + retry_server_errors = False + def default_headers(self) -> dict[str, str]: return self.fingerprint.write_headers(modhash=self.session.modhash) diff --git a/tests/test_cli.py b/tests/test_cli.py index 4b53c89..c52418d 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1159,7 +1159,7 @@ def test_embed_publish_substitutes_markers(self, tmp_path): patch("rdt_cli.client.RedditClient.resolve_subreddit_id", return_value="t5_abc"), \ patch( "rdt_cli.client.RedditClient.upload_image_as_embed", - return_value="https://i.redd.it/mid1.jpg", + return_value=("mid1", "https://i.redd.it/mid1.jpg"), ) as mock_up, \ patch("rdt_cli.client.RedditClient.create_post", return_value={}) as mock_post: result = runner.invoke( @@ -1171,8 +1171,19 @@ def test_embed_publish_substitutes_markers(self, tmp_path): mock_up.assert_called_once_with(str(img)) _, kwargs = mock_post.call_args assert kwargs["kind"] == "self" + # marker-substituted markdown still computed (drafts use it; on + # publish create_post ignores body once rich_text is given) assert kwargs["body"] == "before [cat pic](https://i.redd.it/mid1.jpg) after" assert "![img]" not in kwargs["body"] + # rich_text: markers become RTJSON img blocks (true inline images) + rtjson = json.loads(kwargs["rich_text"]) + assert rtjson == { + "document": [ + {"e": "par", "c": [{"e": "text", "t": "before "}]}, + {"e": "img", "id": "mid1", "c": "cat pic"}, + {"e": "par", "c": [{"e": "text", "t": " after"}]}, + ] + } def test_embed_draft_substitutes_markers(self, tmp_path): cred = self._cred() @@ -1184,7 +1195,7 @@ def test_embed_draft_substitutes_markers(self, tmp_path): patch("rdt_cli.client.RedditClient.resolve_subreddit_id", return_value="t5_abc"), \ patch( "rdt_cli.client.RedditClient.upload_image_as_embed", - return_value="https://i.redd.it/mid2.png", + return_value=("mid2", "https://i.redd.it/mid2.png"), ), \ patch("rdt_cli.client.RedditClient.create_draft", return_value={}) as mock_draft: result = runner.invoke( @@ -1192,6 +1203,7 @@ def test_embed_draft_substitutes_markers(self, tmp_path): ) assert result.exit_code == 0, result.output _, kwargs = mock_draft.call_args + # drafts are markdown-only (no RTJSON media blocks) โ€” links fallback assert kwargs["body"] == "see [pic](https://i.redd.it/mid2.png)" def test_profile_publish_uses_profile_flag(self): @@ -1213,7 +1225,7 @@ def test_requires_a_kind(self): with patch("rdt_cli.commands._common.get_credential", return_value=cred): result = runner.invoke(cli, ["post", "test", "No body", "--draft"]) assert result.exit_code == 2 - assert "exactly one" in result.output + assert "Provide --text, --url, or --image" in result.output def test_rejects_two_kinds(self): cred = self._cred() @@ -1222,7 +1234,67 @@ def test_rejects_two_kinds(self): cli, ["post", "test", "Two", "--text", "a", "--url", "https://e.com", "--draft"] ) assert result.exit_code == 2 - assert "exactly one" in result.output + assert "Provide --text, --url, or --image" in result.output + + def test_text_with_attached_image_publishes_self_with_media(self, tmp_path): + cred = self._cred() + img = tmp_path / "chart.png" + img.write_bytes(b"\x89PNG\r\n\x1a\n") + with patch("rdt_cli.commands._common.get_credential", return_value=cred), \ + patch("rdt_cli.commands.submit.write_delay"), \ + patch("rdt_cli.client.RedditClient.validate_session", return_value={}), \ + patch("rdt_cli.client.RedditClient.resolve_subreddit_id", return_value="t5_abc"), \ + patch("rdt_cli.client.RedditClient.upload_image", return_value="mid9") as mock_up, \ + patch("rdt_cli.client.RedditClient.create_post", return_value={}) as mock_post: + result = runner.invoke( + cli, ["post", "test", "T", "--text", "body", "--image", str(img), + "--recaptcha-token", "TOK", "--json"], + ) + assert result.exit_code == 0, result.output + mock_up.assert_called_once_with(str(img)) + _, kwargs = mock_post.call_args + # --text + --image โ†’ still a text post, image attached via media_id + assert kwargs["kind"] == "self" + assert kwargs["body"] == "body" + assert kwargs["media_id"] == "mid9" + + def test_embed_combines_with_attached_image(self, tmp_path): + cred = self._cred() + inline = tmp_path / "inline.png" + inline.write_bytes(b"\x89PNG\r\n\x1a\n") + attach = tmp_path / "attach.png" + attach.write_bytes(b"\x89PNG\r\n\x1a\n") + with patch("rdt_cli.commands._common.get_credential", return_value=cred), \ + patch("rdt_cli.commands.submit.write_delay"), \ + patch("rdt_cli.client.RedditClient.validate_session", return_value={}), \ + patch( + "rdt_cli.client.RedditClient.upload_image_as_embed", + return_value=("mid1", "https://i.redd.it/mid1.png"), + ), \ + patch("rdt_cli.client.RedditClient.upload_image", return_value="mid2") as mock_up, \ + patch("rdt_cli.client.RedditClient.create_post", return_value={}) as mock_post: + result = runner.invoke( + cli, ["post", "test", "T", "--text", "a ![img] b", "--embed", str(inline), + "--image", str(attach), "--recaptcha-token", "TOK", "--json"], + ) + assert result.exit_code == 0, result.output + # hybrid: inline RTJSON embed + attached image (feed preview) together + mock_up.assert_called_once_with(str(attach)) + _, kwargs = mock_post.call_args + assert kwargs["kind"] == "self" + assert kwargs["media_id"] == "mid2" + assert json.loads(kwargs["rich_text"])["document"][1]["id"] == "mid1" + + def test_rejects_text_with_image_draft(self, tmp_path): + cred = self._cred() + img = tmp_path / "pic.png" + img.write_bytes(b"\x89PNG\r\n\x1a\n") + with patch("rdt_cli.commands._common.get_credential", return_value=cred): + result = runner.invoke( + cli, ["post", "test", "T", "--text", "body", "--image", str(img), "--draft"] + ) + assert result.exit_code == 2 + assert "draft" in result.output.lower() def test_rejects_image_draft(self, tmp_path): cred = self._cred() @@ -1297,14 +1369,140 @@ def test_create_draft_payload(self): assert inp["kind"] == "MARKDOWN" assert inp["content"] == {"markdown": "hello"} + def test_create_post_self_with_rich_text(self): + with self._client() as client: + with patch.object(client, "_graphql", return_value={}) as g: + client.create_post( + "test", "T", recaptcha_token="TOK", kind="self", + body="before [s](https://i.redd.it/m1.jpg) after", + rich_text='{"document":[{"e":"par","c":[{"e":"text","t":"before "}]},' + '{"e":"img","id":"m1","c":"s"},' + '{"e":"par","c":[{"e":"text","t":" after"}]}]}', + ) + op, variables = g.call_args.args + assert op == "CreatePost" + inp = variables["input"] + # captured community requests address by name, without postType + assert inp["subredditName"] == "test" + assert "postType" not in inp + assert "subredditId" not in inp + content = inp["content"] + # editor-style: content carries the RTJSON alone, never a + # markdown+richText pair (body is ignored when rich_text given) + assert "markdown" not in content + rtjson = json.loads(content["richText"]) + assert rtjson["document"][1] == {"e": "img", "id": "m1", "c": "s"} + + def test_5xx_surfaces_response_body_in_error(self): + import httpx + + from rdt_cli.exceptions import RedditApiError + + with self._client() as client: + transport = client._write_transport + resp = httpx.Response( + 500, + text='{"errors":[{"message":"SUBREDDIT_NOT_ALLOWED"}]}', + request=httpx.Request("POST", "https://www.reddit.com/svc/shreddit/graphql"), + ) + with patch.object(transport.client, "request", return_value=resp) as mock_req, \ + patch("rdt_cli.transports.time.sleep"): + with pytest.raises(RedditApiError) as exc_info: + transport.request("POST", "/svc/shreddit/graphql") + # the server's error body must reach the user, not a bare retry count + assert "HTTP 500" in str(exc_info.value) + assert "SUBREDDIT_NOT_ALLOWED" in str(exc_info.value) + # writes fail fast: no 5xx retries (mutations aren't idempotent and + # recaptcha tokens are single-use) + assert mock_req.call_count == 1 + + def test_read_transport_retries_5xx(self): + import httpx + + from rdt_cli.exceptions import RedditApiError + + with self._client() as client: + transport = client._read_transport + resp = httpx.Response( + 503, + text="upstream connect error", + request=httpx.Request("GET", "https://www.reddit.com/r/test.json"), + ) + with patch.object(transport.client, "request", return_value=resp) as mock_req, \ + patch("rdt_cli.transports.time.sleep"): + with pytest.raises(RedditApiError) as exc_info: + transport.request("GET", "/r/test.json") + assert "HTTP 503" in str(exc_info.value) + assert "upstream connect error" in str(exc_info.value) + # reads keep the backoff-and-retry behavior, exhausting max_retries + assert mock_req.call_count == client._max_retries + + def test_create_post_self_with_attached_image(self): + with self._client() as client: + with patch.object(client, "_graphql", return_value={}) as g: + client.create_post( + "test", "T", recaptcha_token="TOK", kind="self", + body="text", media_id="mid7", + ) + op, variables = g.call_args.args + assert op == "CreatePost" + inp = variables["input"] + # captured traffic: text posts attach via image.url even on + # subreddits (gallery.items is for pure image posts only) + assert inp["subredditName"] == "test" + assert inp["isCommercialCommunication"] is False + assert inp["targetLanguage"] == "" + assert inp["content"] == {"markdown": "text"} + assert inp["image"] == { + "url": "https://reddit-uploaded-media.s3-accelerate.amazonaws.com/mid7" + } + assert "gallery" not in inp + + def test_create_post_self_rich_text_with_attached_image(self): + with self._client() as client: + with patch.object(client, "_graphql", return_value={}) as g: + client.create_post( + "test", "T", recaptcha_token="TOK", kind="self", + rich_text='{"document":[{"e":"img","id":"m1"}]}', media_id="mid9", + ) + _, variables = g.call_args.args + inp = variables["input"] + assert inp["content"] == {"richText": '{"document":[{"e":"img","id":"m1"}]}'} + assert inp["image"]["url"].endswith("/mid9") + + def test_create_profile_post_self_with_attached_image(self): + with self._client() as client: + with patch.object(client, "_graphql", return_value={}) as g: + client.create_post( + "t5_x", "T", recaptcha_token="TOK", kind="self", + body="text", media_id="mid8", is_profile=True, + ) + op, variables = g.call_args.args + assert op == "CreateProfilePost" + inp = variables["input"] + assert inp["content"] == {"markdown": "text"} + assert inp["image"] == { + "url": "https://reddit-uploaded-media.s3-accelerate.amazonaws.com/mid8" + } + + def test_create_post_self_without_rich_text_omits_field(self): + with self._client() as client: + with patch.object(client, "_graphql", return_value={}) as g: + client.create_post("t5_x", "T", recaptcha_token="TOK", kind="self", body="hi") + _, variables = g.call_args.args + content = variables["input"]["content"] + assert content == {"markdown": "hi"} + assert "richText" not in content + def test_create_post_link_payload(self): with self._client() as client: with patch.object(client, "_graphql", return_value={}) as g: - client.create_post("t5_x", "T", recaptcha_token="TOK", kind="link", url="https://e.com") + client.create_post("test", "T", recaptcha_token="TOK", kind="link", url="https://e.com") op, variables = g.call_args.args assert op == "CreatePost" inp = variables["input"] - assert inp["postType"] == "LINK" + assert inp["subredditName"] == "test" + assert "postType" not in inp assert inp["url"] == "https://e.com" assert inp["recaptchaToken"] == "TOK" assert "correlationId" in inp @@ -1321,7 +1519,7 @@ def test_create_profile_post_image_payload(self): inp = variables["input"] assert inp["image"]["url"].endswith("/mid42") assert inp["recaptchaToken"] == "TOK" - assert "subredditId" not in inp # profile posts omit subredditId + assert "subredditName" not in inp # profile posts have no target def test_upload_image(self, tmp_path): img = tmp_path / "pic.png" @@ -1351,11 +1549,70 @@ def test_upload_image_as_embed(self, tmp_path): img.write_bytes(b"\x00") with self._client() as client: with patch.object(client, "upload_image", return_value="mid42") as mock_up: - url = client.upload_image_as_embed(str(img)) + media_id, url = client.upload_image_as_embed(str(img)) + assert media_id == "mid42" assert url == f"https://i.redd.it/mid42.{ext}" mock_up.assert_called_once_with(str(img)) +# โ”€โ”€ RTJSON building for inline embeds โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + +class TestRichtext: + def test_interleaves_paragraphs_and_images(self): + from rdt_cli.richtext import build_rtjson + + doc = json.loads(build_rtjson("before ![img] after", [("mid1", "cat pic")])) + assert doc == { + "document": [ + {"e": "par", "c": [{"e": "text", "t": "before "}]}, + {"e": "img", "id": "mid1", "c": "cat pic"}, + {"e": "par", "c": [{"e": "text", "t": " after"}]}, + ] + } + + def test_multiple_images_in_order(self): + from rdt_cli.richtext import build_rtjson + + doc = json.loads(build_rtjson("a ![img] b ![img] c", [("m1", "one"), ("m2", "two")])) + assert doc["document"] == [ + {"e": "par", "c": [{"e": "text", "t": "a "}]}, + {"e": "img", "id": "m1", "c": "one"}, + {"e": "par", "c": [{"e": "text", "t": " b "}]}, + {"e": "img", "id": "m2", "c": "two"}, + {"e": "par", "c": [{"e": "text", "t": " c"}]}, + ] + + def test_marker_at_edges_adds_no_empty_paragraphs(self): + from rdt_cli.richtext import build_rtjson + + doc = json.loads(build_rtjson("![img]", [("m1", "")])) + assert doc["document"] == [{"e": "img", "id": "m1"}] # empty caption omitted + + def test_newlines_become_paragraphs(self): + from rdt_cli.richtext import build_rtjson + + doc = json.loads(build_rtjson("line1\n\nline2 ![img]", [("m1", "cap")])) + # blank lines are dropped โ€” editor documents never hold empty par nodes + assert doc["document"] == [ + {"e": "par", "c": [{"e": "text", "t": "line1"}]}, + {"e": "par", "c": [{"e": "text", "t": "line2 "}]}, + {"e": "img", "id": "m1", "c": "cap"}, + ] + + def test_empty_body_yields_single_empty_paragraph(self): + from rdt_cli.richtext import build_rtjson + + doc = json.loads(build_rtjson("", [])) + assert doc["document"] == [{"e": "par", "c": [{"e": "text", "t": ""}]}] + + def test_marker_media_count_mismatch_raises(self): + from rdt_cli.richtext import build_rtjson + + with pytest.raises(ValueError): + build_rtjson("![img] ![img]", [("m1", "one")]) + + # โ”€โ”€ Captcha solving via Solvecaptcha (mocked HTTP) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ From 4ce6743ba1e73c778eccb5e787c9d562069d6d90 Mon Sep 17 00:00:00 2001 From: Nikolay Bryskin <nikicat@users.noreply.github.com> Date: Wed, 22 Jul 2026 16:33:03 +0300 Subject: [PATCH 6/8] =?UTF-8?q?feat:=20post=20flair=20support=20=E2=80=94?= =?UTF-8?q?=20`rdt=20flairs`=20+=20--flair-id/--flair-text?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `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> --- README.md | 9 ++++ SKILL.md | 4 ++ rdt_cli/cli.py | 1 + rdt_cli/client.py | 24 +++++++++++ rdt_cli/commands/submit.py | 51 +++++++++++++++++++++++ tests/test_cli.py | 84 ++++++++++++++++++++++++++++++++++++++ 6 files changed, 173 insertions(+) diff --git a/README.md b/README.md index 7f36125..c2a93f5 100644 --- a/README.md +++ b/README.md @@ -134,8 +134,17 @@ rdt post pics "My cat" --image cat.jpg # Publish image ( rdt post u_yourname "On my profile" --text "hi" # Publish to profile rdt post python "Guide" --text "step 1 ![img] done" --embed step1.jpg # Inline image in a text post rdt post python "Report" --text "summary **md**" --image chart.png # Text post + attached image +rdt flairs askscience # List post flair templates (id + text) +rdt post askscience "Q" --text "โ€ฆ" --flair-id <template-id> # Publish with flair (some subs require it) ``` +> **Post flair (live-verified):** some communities require flair. `rdt flairs +> <subreddit>` lists the templates; pass the id via `--flair-id` when +> publishing (plus `--flair-text` to override text-editable templates). +> Publish-only, community posts only. Careful: a template with empty default +> text still applies without `--flair-text`, but renders as a blank +> (invisible) label โ€” the listing flags such templates. + > **Attached image in a text post:** `--text` + `--image` publishes a markdown > text post with the image attached (`image.url` in the mutation input): it > renders after the body, uncaptioned. The body stays markdown, so formatting diff --git a/SKILL.md b/SKILL.md index 3092f40..71f53b0 100644 --- a/SKILL.md +++ b/SKILL.md @@ -140,6 +140,10 @@ Exactly one of `--text` / `--url` / `--image`. Target a subreddit or a profile ( | `rdt post <sub> <title> --image <file>` | Publish image post (captcha auto-solved if key set) | `rdt post pics "Cat" --image cat.jpg` | | `rdt post <sub> <title> --text <body> --embed <file>` | Text post with inline image(s) | `rdt post python "Guide" --text "step 1 ![img]" --embed s1.jpg` | | `rdt post <sub> <title> --text <body> --image <file>` | Text post + attached image (after body, no caption) | `rdt post python "Report" --text "summary" --image chart.png` | +| `rdt flairs <sub>` | List post flair templates (id + text) | `rdt flairs askscience` | +| `rdt post <sub> <title> โ€ฆ --flair-id <id>` | Publish with post flair (required by some subs; `--flair-text` for editable templates) | `rdt post askscience "Q" --text "โ€ฆ" --flair-id abc-123` | + +Flair gotcha: a template with empty default text applies fine but renders as a blank label โ€” pass `--flair-text` with it (the `rdt flairs` listing flags such templates). Optional flags: `--nsfw`, `--spoiler`. Notes: Reddit drafts can't store images (text/link only); `--image` requires publishing with a token (flag or solver); `--embed` may combine with `--image`: the attachment becomes the feed-card image and also renders after the body in the opened post (inline embeds render in place there) โ€” attach a distinct image to avoid an in-post duplicate. `--embed` needs one `![img]` marker per file in `--text` (substituted in order). On publish with embeds the body goes out as RTJSON (`content.richText`, replacing markdown; without embeds it stays `content.markdown`): New Reddit renders true inline image blocks with captions. Keep `--text` plain when embedding (markdown formatting isn't translated to RTJSON and shows literally). Drafts are markdown-only โ€” embeds stay `https://i.redd.it/<id>.<ext>` links there. Feed visibility: `--embed` images show only inside the opened post (feed cards show text only); an attached `--image` also shows on feed/list cards โ€” choose by whether feed visibility or inline placement/captions matters more. diff --git a/rdt_cli/cli.py b/rdt_cli/cli.py index 4ccb3eb..b839a0f 100644 --- a/rdt_cli/cli.py +++ b/rdt_cli/cli.py @@ -74,6 +74,7 @@ def cli(ctx: click.Context, verbose: bool) -> None: # โ”€โ”€โ”€ Create commands โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ cli.add_command(submit.post) +cli.add_command(submit.flairs) if __name__ == "__main__": diff --git a/rdt_cli/client.py b/rdt_cli/client.py index 500eba2..55eeffc 100644 --- a/rdt_cli/client.py +++ b/rdt_cli/client.py @@ -211,6 +211,16 @@ def get_subreddit_about(self, subreddit: str) -> dict: data = self._get(SUBREDDIT_ABOUT_URL.format(subreddit=subreddit), params={"raw_json": 1}) return data.get("data", data) + def get_link_flairs(self, subreddit: str) -> list[dict[str, Any]]: + """List a subreddit's post flair templates (``link_flair_v2.json``). + + Returns the raw template list (``id``, ``text``, ``text_editable``, โ€ฆ) + for use with post flair selection. Needs auth cookies; communities + that disallow user flair selection respond 403. + """ + data = self._get(f"/r/{subreddit}/api/link_flair_v2.json", params={"raw_json": 1}) + return data if isinstance(data, list) else [] + # โ”€โ”€ Post / Comments โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ def get_post_comments( @@ -481,6 +491,8 @@ def create_post( url: str | None = None, media_id: str | None = None, rich_text: str | None = None, + flair_id: str | None = None, + flair_text: str | None = None, nsfw: bool = False, spoiler: bool = False, is_profile: bool = False, @@ -509,6 +521,13 @@ def create_post( RTJSON canonically and derives the markdown export from it, so ``body`` is ignored when ``rich_text`` is given. RTJSON is the only way to get true inline image blocks โ€” a markdown body renders image links as links. + + ``flair_id`` (a template id from :meth:`get_link_flairs`) attaches post + flair, with ``flair_text`` overriding the text for editable templates. + Community posts only. Confirmed live: the template id and text land on + the post (``link_flair_template_id``/``link_flair_text``). A template + with empty default text needs ``flair_text``, else the applied flair + renders as a blank label. """ if kind == "self": content = {"richText": rich_text} if rich_text else self._markdown_content(body) @@ -534,6 +553,11 @@ def create_post( return self._graphql(OP_CREATE_PROFILE_POST, {"input": inp}) inp["subredditName"] = subreddit_name.removeprefix("r/") + if flair_id: + flair: dict[str, Any] = {"id": flair_id} + if flair_text: + flair["text"] = flair_text + inp["flair"] = flair if kind == "image": inp["gallery"] = {"items": [{"mediaId": media_id}]} return self._graphql(OP_CREATE_POST, {"input": inp}) diff --git a/rdt_cli/commands/submit.py b/rdt_cli/commands/submit.py index 15b330c..d4d900d 100644 --- a/rdt_cli/commands/submit.py +++ b/rdt_cli/commands/submit.py @@ -29,6 +29,7 @@ from ._common import ( console, exit_for_error, + handle_command, maybe_print_structured, require_auth, structured_output_options, @@ -36,6 +37,33 @@ ) +@click.command() +@click.argument("subreddit") +@structured_output_options +def flairs(subreddit: str, as_json: bool, as_yaml: bool) -> None: + """List a subreddit's post flair templates (for `rdt post --flair-id`). + + Some communities require post flair; pick a template id here and pass it + via --flair-id when publishing. + """ + cred = require_auth() + + def _render(data: list[dict]) -> None: + if not data: + console.print("[dim]No selectable post flairs.[/dim]") + return + for f in data: + editable = " [dim](text editable)[/dim]" if f.get("text_editable") else "" + text = f.get("text") or "[yellow](no default text โ€” pass --flair-text or the flair shows blank)[/yellow]" + console.print(f"[bold cyan]{f.get('id', '')}[/bold cyan] {text}{editable}") + + handle_command( + cred, + action=lambda c: c.get_link_flairs(subreddit), + render=_render, as_json=as_json, as_yaml=as_yaml, + ) + + def _find_first(obj: Any, keys: set[str]) -> str | None: """Depth-first search for the first non-empty string under any of ``keys``.""" if isinstance(obj, dict): @@ -111,6 +139,14 @@ def _embed_images(text: str, embeds: tuple[str, ...], urls: list[str]) -> str: help="reCAPTCHA Enterprise token from a browser (otherwise a token is " "bought via Solvecaptcha when an API key is configured)", ) +@click.option( + "--flair-id", "flair_id", default=None, + help="Post flair template id (see `rdt flairs <subreddit>`; publish only)", +) +@click.option( + "--flair-text", "flair_text", default=None, + help="Custom flair text (only for text-editable flair templates)", +) @click.option("--nsfw", is_flag=True, help="Mark as NSFW") @click.option("--spoiler", is_flag=True, help="Mark as spoiler") @structured_output_options @@ -123,6 +159,8 @@ def post( draft: bool, embeds: tuple[str, ...], recaptcha_token: str | None, + flair_id: str | None, + flair_text: str | None, nsfw: bool, spoiler: bool, as_json: bool, @@ -143,12 +181,17 @@ def post( API key (env RDT_SOLVECAPTCHA_API_KEY or APIKEY_SOLVECAPTCHA) and a token is bought automatically right before publishing. + Communities that require post flair: list templates with `rdt flairs + <subreddit>` and pass --flair-id (plus --flair-text for text-editable + templates). Flair applies at publish only, community posts only. + Examples: rdt post python "My title" --text "Hello **world**" --draft rdt post news "Interesting" --url https://example.com --draft rdt post pics "My cat" --image cat.jpg --recaptcha-token <token> rdt post python "Guide" --text "step 1 ![img] done" --embed step1.jpg rdt post u_myname "On my profile" --text "hi" # solves via Solvecaptcha + rdt post askscience "Q" --text "โ€ฆ" --flair-id 1234-abcd """ provided = [ name for name, given in @@ -187,6 +230,11 @@ def post( "text/link only." ) + if flair_text and not flair_id: + raise click.UsageError("--flair-text requires --flair-id (a template id from `rdt flairs`).") + if flair_id and draft: + raise click.UsageError("Flair applies at publish time โ€” drafts don't store it.") + # Token source for publishing: explicit flag wins; otherwise a Solvecaptcha # API key must be configured (a token is bought just before publishing). solver_api_key: str | None = None @@ -196,6 +244,8 @@ def post( raise click.UsageError(_RECAPTCHA_HELP) is_profile = subreddit.lower().startswith("u_") + if flair_id and is_profile: + raise click.UsageError("Flair is a community feature โ€” profile posts can't take one.") cred = require_auth() try: @@ -233,6 +283,7 @@ def post( data = client.create_post( subreddit, title, recaptcha_token=recaptcha_token, kind=kind, body=text, url=link_url, media_id=media_id, rich_text=rich_text, + flair_id=flair_id, flair_text=flair_text, nsfw=nsfw, spoiler=spoiler, is_profile=is_profile, ) action = "Posted" diff --git a/tests/test_cli.py b/tests/test_cli.py index c52418d..33696d5 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1296,6 +1296,65 @@ def test_rejects_text_with_image_draft(self, tmp_path): assert result.exit_code == 2 assert "draft" in result.output.lower() + def test_flairs_lists_templates(self): + cred = self._cred() + templates = [ + {"id": "aaa-111", "text": "Discussion", "text_editable": False}, + {"id": "bbb-222", "text": "", "text_editable": True}, + ] + with patch("rdt_cli.commands._common.get_credential", return_value=cred), \ + patch("rdt_cli.client.RedditClient.get_link_flairs", return_value=templates), \ + patch("rdt_cli.commands._common.resolve_output_format", return_value=None): + result = runner.invoke(cli, ["flairs", "test"]) + assert result.exit_code == 0, result.output + assert "aaa-111" in result.output + assert "Discussion" in result.output + # empty-default templates apply but render blank โ€” the listing warns + assert "no default text" in result.output + + def test_post_with_flair_passes_through(self): + cred = self._cred() + with patch("rdt_cli.commands._common.get_credential", return_value=cred), \ + patch("rdt_cli.commands.submit.write_delay"), \ + patch("rdt_cli.client.RedditClient.validate_session", return_value={}), \ + patch("rdt_cli.client.RedditClient.create_post", return_value={}) as mock_post: + result = runner.invoke( + cli, ["post", "test", "T", "--text", "body", "--flair-id", "aaa-111", + "--flair-text", "Custom", "--recaptcha-token", "TOK", "--json"], + ) + assert result.exit_code == 0, result.output + _, kwargs = mock_post.call_args + assert kwargs["flair_id"] == "aaa-111" + assert kwargs["flair_text"] == "Custom" + + def test_flair_text_requires_flair_id(self): + cred = self._cred() + with patch("rdt_cli.commands._common.get_credential", return_value=cred): + result = runner.invoke( + cli, ["post", "test", "T", "--text", "body", "--flair-text", "Custom"] + ) + assert result.exit_code == 2 + assert "--flair-text requires --flair-id" in result.output + + def test_flair_rejected_for_draft(self): + cred = self._cred() + with patch("rdt_cli.commands._common.get_credential", return_value=cred): + result = runner.invoke( + cli, ["post", "test", "T", "--text", "body", "--flair-id", "aaa", "--draft"] + ) + assert result.exit_code == 2 + assert "publish" in result.output.lower() + + def test_flair_rejected_for_profile(self): + cred = self._cred() + with patch("rdt_cli.commands._common.get_credential", return_value=cred): + result = runner.invoke( + cli, ["post", "u_me", "T", "--text", "body", "--flair-id", "aaa", + "--recaptcha-token", "TOK"] + ) + assert result.exit_code == 2 + assert "community" in result.output.lower() + def test_rejects_image_draft(self, tmp_path): cred = self._cred() img = tmp_path / "pic.png" @@ -1458,6 +1517,31 @@ def test_create_post_self_with_attached_image(self): } assert "gallery" not in inp + def test_create_post_with_flair(self): + with self._client() as client: + with patch.object(client, "_graphql", return_value={}) as g: + client.create_post( + "test", "T", recaptcha_token="TOK", kind="self", body="hi", + flair_id="aaa-111", flair_text="Custom", + ) + _, variables = g.call_args.args + assert variables["input"]["flair"] == {"id": "aaa-111", "text": "Custom"} + + def test_create_post_without_flair_omits_field(self): + with self._client() as client: + with patch.object(client, "_graphql", return_value={}) as g: + client.create_post("test", "T", recaptcha_token="TOK", kind="self", body="hi") + _, variables = g.call_args.args + assert "flair" not in variables["input"] + + def test_get_link_flairs(self): + templates = [{"id": "aaa", "text": "Discussion", "text_editable": False}] + with self._client() as client: + with patch.object(client, "_get", return_value=templates) as g: + assert client.get_link_flairs("test") == templates + url, = g.call_args.args + assert url == "/r/test/api/link_flair_v2.json" + def test_create_post_self_rich_text_with_attached_image(self): with self._client() as client: with patch.object(client, "_graphql", return_value={}) as g: From 6a598dff5e1c09b9f986478b1d69b5cdfa92cc7a Mon Sep 17 00:00:00 2001 From: Nikolay Bryskin <nikicat@users.noreply.github.com> Date: Thu, 23 Jul 2026 15:53:49 +0300 Subject: [PATCH 7/8] =?UTF-8?q?feat:=20add=20`rdt=20rules`=20=E2=80=94=20v?= =?UTF-8?q?iew=20subreddit=20rules?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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> --- README.md | 4 +++- SKILL.md | 1 + rdt_cli/cli.py | 1 + rdt_cli/client.py | 10 ++++++++++ rdt_cli/commands/browse.py | 39 ++++++++++++++++++++++++++++++++++++++ rdt_cli/constants.py | 1 + tests/test_cli.py | 33 ++++++++++++++++++++++++++++++-- tests/test_smoke.py | 14 ++++++++++++++ 8 files changed, 100 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index c2a93f5..debfeb3 100644 --- a/README.md +++ b/README.md @@ -81,6 +81,7 @@ rdt all # /r/all rdt sub python # Browse subreddit rdt sub programming -s top -t week # Sort + time filter rdt sub-info python # Subreddit info (subscribers, etc.) +rdt rules python # Subreddit rules rdt user spez # User profile rdt user-posts spez # User's submitted posts rdt user-comments spez # User's comments @@ -289,7 +290,7 @@ rdt_cli/ โ””โ”€โ”€ commands/ โ”œโ”€โ”€ _common.py # Shared helpers (envelope, output routing, formatters) โ”œโ”€โ”€ auth.py # login, logout, status, whoami - โ”œโ”€โ”€ browse.py # feed, popular, all, sub, sub-info, user, user-posts, user-comments, saved, upvoted, open + โ”œโ”€โ”€ browse.py # feed, popular, all, sub, sub-info, rules, user, user-posts, user-comments, saved, upvoted, open โ”œโ”€โ”€ post.py # read, show โ”œโ”€โ”€ search.py # search, export โ”œโ”€โ”€ social.py # upvote, save, subscribe, comment @@ -408,6 +409,7 @@ rdt all # /r/all rdt sub python # ๆต่งˆๅญ็‰ˆๅ— rdt sub programming -s top -t week # ๆŽ’ๅบ + ๆ—ถ้—ด่ฟ‡ๆปค rdt sub-info python # ๅญ็‰ˆๅ—ไฟกๆฏ +rdt rules python # ๅญ็‰ˆๅ—่ง„ๅˆ™ rdt user spez # ็”จๆˆท่ต„ๆ–™ rdt user-posts spez # ็”จๆˆทๅ‘ๅธ– rdt user-comments spez # ็”จๆˆท่ฏ„่ฎบ diff --git a/SKILL.md b/SKILL.md index 71f53b0..1137208 100644 --- a/SKILL.md +++ b/SKILL.md @@ -87,6 +87,7 @@ Payloads live under `.data`. | `rdt all` | Browse /r/all | `rdt all -n 10 --compact --json` | | `rdt sub <name>` | Browse a subreddit | `rdt sub python -s top -t week` | | `rdt sub-info <name>` | View subreddit info | `rdt sub-info rust --json` | +| `rdt rules <name>` | View subreddit rules | `rdt rules rust --json` | | `rdt user <name>` | View user profile | `rdt user spez --json` | | `rdt user-posts <name>` | View user's posts | `rdt user-posts spez -n 5 --json` | | `rdt user-comments <name>` | View user's comments | `rdt user-comments spez -n 5 --json` | diff --git a/rdt_cli/cli.py b/rdt_cli/cli.py index b839a0f..38b489f 100644 --- a/rdt_cli/cli.py +++ b/rdt_cli/cli.py @@ -47,6 +47,7 @@ def cli(ctx: click.Context, verbose: bool) -> None: cli.add_command(browse.all_cmd) cli.add_command(browse.sub) cli.add_command(browse.sub_info) +cli.add_command(browse.rules) cli.add_command(browse.user) cli.add_command(browse.user_posts) cli.add_command(browse.user_comments) diff --git a/rdt_cli/client.py b/rdt_cli/client.py index 55eeffc..0465d8c 100644 --- a/rdt_cli/client.py +++ b/rdt_cli/client.py @@ -33,6 +33,7 @@ SAVE_URL, SEARCH_URL, SUBREDDIT_ABOUT_URL, + SUBREDDIT_RULES_URL, SUBREDDIT_SEARCH_URL, SUBSCRIBE_URL, SUBSCRIPTIONS_URL, @@ -211,6 +212,15 @@ def get_subreddit_about(self, subreddit: str) -> dict: data = self._get(SUBREDDIT_ABOUT_URL.format(subreddit=subreddit), params={"raw_json": 1}) return data.get("data", data) + def get_subreddit_rules(self, subreddit: str) -> dict: + """Get subreddit rules (``about/rules.json``). + + Returns the raw response: ``rules`` (community rules, each with + ``short_name``, ``description``, ``kind``), plus Reddit-wide + ``site_rules``. + """ + return self._get(SUBREDDIT_RULES_URL.format(subreddit=subreddit), params={"raw_json": 1}) + def get_link_flairs(self, subreddit: str) -> list[dict[str, Any]]: """List a subreddit's post flair templates (``link_flair_v2.json``). diff --git a/rdt_cli/commands/browse.py b/rdt_cli/commands/browse.py index 069bbae..ad1586d 100644 --- a/rdt_cli/commands/browse.py +++ b/rdt_cli/commands/browse.py @@ -323,6 +323,45 @@ def _render(data: dict) -> None: ) +# โ”€โ”€ rules โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + +@click.command() +@click.argument("subreddit") +@structured_output_options +def rules(subreddit: str, as_json: bool, as_yaml: bool) -> None: + """View a subreddit's rules""" + cred = optional_auth() + + _KIND_LABELS = {"link": "posts", "comment": "comments", "all": "posts & comments"} + + def _render(data: dict) -> None: + rule_list = data.get("rules", []) + if not rule_list: + console.print(f"[dim]r/{subreddit} has no community rules.[/dim]") + return + + lines = [] + for i, rule in enumerate(rule_list, 1): + name = rule.get("short_name") or rule.get("violation_reason") or f"Rule {i}" + kind = _KIND_LABELS.get(rule.get("kind", ""), "") + kind_tag = f" [dim]({kind})[/dim]" if kind else "" + lines.append(f"[bold cyan]{i}. {name}[/bold cyan]{kind_tag}") + desc = (rule.get("description") or "").strip() + if desc: + lines.append(f"[dim]{desc}[/dim]") + lines.append("") + + panel = Panel("\n".join(lines).rstrip(), title=f"๐Ÿ“œ r/{subreddit} rules", border_style="cyan") + console.print(panel) + + handle_command( + cred, + action=lambda c: c.get_subreddit_rules(subreddit), + render=_render, as_json=as_json, as_yaml=as_yaml, + ) + + # โ”€โ”€ user โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ diff --git a/rdt_cli/constants.py b/rdt_cli/constants.py index 42b032b..2bd613f 100644 --- a/rdt_cli/constants.py +++ b/rdt_cli/constants.py @@ -25,6 +25,7 @@ SUBREDDIT_TOP_URL = "/r/{subreddit}/top.json" SUBREDDIT_RISING_URL = "/r/{subreddit}/rising.json" SUBREDDIT_ABOUT_URL = "/r/{subreddit}/about.json" +SUBREDDIT_RULES_URL = "/r/{subreddit}/about/rules.json" # Post / comments POST_COMMENTS_URL = "/r/{subreddit}/comments/{post_id}.json" diff --git a/tests/test_cli.py b/tests/test_cli.py index 33696d5..9efe4f7 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -32,7 +32,7 @@ def test_all_commands_registered(self): result = runner.invoke(cli, ["--help"]) expected = [ "login", "logout", "status", - "feed", "popular", "all", "sub", "sub-info", "user", + "feed", "popular", "all", "sub", "sub-info", "rules", "user", "user-posts", "user-comments", "saved", "upvoted", "open", "read", "show", "search", "export", @@ -66,7 +66,7 @@ class TestCommandHelp: "cmd", [ "login", "logout", "status", - "feed", "popular", "all", "sub", "sub-info", "user", + "feed", "popular", "all", "sub", "sub-info", "rules", "user", "user-posts", "user-comments", "saved", "upvoted", "open", "read", "show", "search", "export", @@ -657,6 +657,35 @@ def test_sub_info_mocked(self): result = runner.invoke(cli, ["sub-info", "python", "--json"]) assert result.exit_code == 0 + def test_rules_mocked(self): + mock_data = { + "rules": [ + {"kind": "all", "short_name": "Be nice", "description": "No harassment.", "priority": 0}, + {"kind": "link", "short_name": "On topic", "description": "", "priority": 1}, + ], + "site_rules": [], + } + with patch("rdt_cli.auth.get_credential", return_value=None): + with patch("rdt_cli.client.RedditClient.get_subreddit_rules", return_value=mock_data): + result = runner.invoke(cli, ["rules", "python", "--json"]) + assert result.exit_code == 0 + assert "Be nice" in result.output + + def test_rules_rendered_mocked(self): + mock_data = {"rules": [{"kind": "comment", "short_name": "No spam", "description": "Don't spam."}]} + with patch("rdt_cli.auth.get_credential", return_value=None): + with patch("rdt_cli.client.RedditClient.get_subreddit_rules", return_value=mock_data): + result = runner.invoke(cli, ["rules", "python"], env={"OUTPUT": "rich"}) + assert result.exit_code == 0 + assert "No spam" in result.output + + def test_rules_empty_mocked(self): + with patch("rdt_cli.auth.get_credential", return_value=None): + with patch("rdt_cli.client.RedditClient.get_subreddit_rules", return_value={"rules": []}): + result = runner.invoke(cli, ["rules", "python"], env={"OUTPUT": "rich"}) + assert result.exit_code == 0 + assert "no community rules" in result.output + def test_user_mocked(self): mock_data = {"name": "testuser", "link_karma": 100, "comment_karma": 200} with patch("rdt_cli.auth.get_credential", return_value=None): diff --git a/tests/test_smoke.py b/tests/test_smoke.py index 707d11c..7642fb8 100644 --- a/tests/test_smoke.py +++ b/tests/test_smoke.py @@ -142,6 +142,20 @@ def test_sub_info_large(self): assert inner["subscribers"] > 1_000_000 +@smoke +class TestRules: + def test_rules(self): + result = _invoke("rules", "python") + assert result.exit_code == 0 + + def test_rules_json(self): + result, data = _invoke_json("rules", "python") + assert result.exit_code == 0 + if data: + inner = data.get("data", data) + assert "rules" in inner + + @smoke class TestUser: def test_user_profile(self): From d3faa1c96e974b291d20e743dbb577720949675c Mon Sep 17 00:00:00 2001 From: Nikolay Bryskin <nikicat@users.noreply.github.com> Date: Thu, 23 Jul 2026 16:54:47 +0300 Subject: [PATCH 8/8] =?UTF-8?q?feat:=20add=20`rdt=20delete`=20=E2=80=94=20?= =?UTF-8?q?delete=20your=20own=20posts/comments?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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> --- README.md | 4 +++- SKILL.md | 5 +++-- rdt_cli/cli.py | 3 ++- rdt_cli/client.py | 5 +++++ rdt_cli/commands/social.py | 35 +++++++++++++++++++++++++++++++++++ rdt_cli/constants.py | 1 + tests/test_cli.py | 33 +++++++++++++++++++++++++++++++-- 7 files changed, 80 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index debfeb3..dacd4a6 100644 --- a/README.md +++ b/README.md @@ -122,6 +122,7 @@ rdt save 3 --undo # Unsave rdt subscribe python # Subscribe to r/python rdt subscribe python --undo # Unsubscribe rdt comment 3 "Great post!" # Comment on result #3 +rdt delete 1abc123 -y # Delete your own post (t1_โ€ฆ for comments) # โ”€โ”€โ”€ Create posts (require login) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ # Drafts save headlessly. Publishing is gated behind reCAPTCHA Enterprise: @@ -293,7 +294,7 @@ rdt_cli/ โ”œโ”€โ”€ browse.py # feed, popular, all, sub, sub-info, rules, user, user-posts, user-comments, saved, upvoted, open โ”œโ”€โ”€ post.py # read, show โ”œโ”€โ”€ search.py # search, export - โ”œโ”€โ”€ social.py # upvote, save, subscribe, comment + โ”œโ”€โ”€ social.py # upvote, save, subscribe, comment, delete โ””โ”€โ”€ submit.py # post (text/link/image, drafts) ``` @@ -433,6 +434,7 @@ rdt upvote 3 # ็‚น่ตž rdt save 3 # ๆ”ถ่— rdt subscribe python # ่ฎข้˜… rdt comment 3 "Great post!" # ่ฏ„่ฎบ +rdt delete 1abc123 -y # ๅˆ ้™ค่‡ชๅทฑ็š„ๅธ–ๅญ/่ฏ„่ฎบ ``` ## ่ฎค่ฏ็ญ–็•ฅ diff --git a/SKILL.md b/SKILL.md index 1137208..7aa2883 100644 --- a/SKILL.md +++ b/SKILL.md @@ -73,7 +73,7 @@ Payloads live under `.data`. - `--output file.json` โ†’ save structured output to file - Rich output โ†’ **stderr** (safe for pipes: `rdt search X --json | jq .data`) - Most read commands work without auth (public Reddit JSON API) -- Write actions (upvote, save, subscribe, comment, post) require auth + built-in 1.5-4s delay +- Write actions (upvote, save, subscribe, comment, delete, post) require auth + built-in 1.5-4s delay ## Command Reference @@ -126,6 +126,7 @@ Payloads live under `.data`. | `rdt subscribe <sub>` | Subscribe | `rdt subscribe python` | | `rdt subscribe <sub> --undo` | Unsubscribe | `rdt subscribe python --undo` | | `rdt comment <id> <text>` | Post a comment | `rdt comment 3 "Great post!"` | +| `rdt delete <id> -y` | Delete your own post/comment (irreversible; `-y` skips the prompt; use `t1_<id>` for comments) | `rdt delete 1abc123 -y` | ### Create posts (require auth) @@ -259,7 +260,7 @@ Structured error codes returned in the `error.code` field (see [SCHEMA.md](./SCH ## Anti-Detection Notes for Agents - **Do NOT parallelize requests** โ€” the built-in rate-limit delay is for account safety -- **Write operation delay**: 1.5-4s random delay after each write (upvote/save/subscribe/comment) +- **Write operation delay**: 1.5-4s random delay after each write (upvote/save/subscribe/comment/delete) - **Batch operations**: add delays between CLI calls when doing bulk work - **Chrome 133 fingerprint**: all requests use consistent browser identity - **Exponential backoff**: 429/5xx errors are auto-retried with backoff diff --git a/rdt_cli/cli.py b/rdt_cli/cli.py index 38b489f..e43cfb5 100644 --- a/rdt_cli/cli.py +++ b/rdt_cli/cli.py @@ -7,7 +7,7 @@ rdt search <query> / export <query> rdt user <username> / user-posts <username> / user-comments <username> rdt saved / upvoted - rdt upvote / save / subscribe / comment + rdt upvote / save / subscribe / comment / delete """ from __future__ import annotations @@ -71,6 +71,7 @@ def cli(ctx: click.Context, verbose: bool) -> None: cli.add_command(social.save) cli.add_command(social.subscribe) cli.add_command(social.comment) +cli.add_command(social.delete) # โ”€โ”€โ”€ Create commands โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ diff --git a/rdt_cli/client.py b/rdt_cli/client.py index 0465d8c..a45315a 100644 --- a/rdt_cli/client.py +++ b/rdt_cli/client.py @@ -16,6 +16,7 @@ BASE_URL, COMMENT_URL, DEFAULT_LIMIT, + DEL_URL, GRAPHQL_URL, HOME_URL, IMAGE_MIME_TO_EXT, @@ -378,6 +379,10 @@ def unsave_item(self, fullname: str) -> dict: """Unsave a post or comment.""" return self._post(UNSAVE_URL, data={"id": fullname}) + def delete_item(self, fullname: str) -> dict: + """Delete your own post (t3_*) or comment (t1_*). Irreversible.""" + return self._post(DEL_URL, data={"id": fullname}) + def subscribe(self, subreddit: str, action: str = "sub") -> dict: """Subscribe or unsubscribe. action: 'sub' or 'unsub'.""" return self._post(SUBSCRIBE_URL, data={"sr_name": subreddit, "action": action}) diff --git a/rdt_cli/commands/social.py b/rdt_cli/commands/social.py index 9c4758f..aab87ad 100644 --- a/rdt_cli/commands/social.py +++ b/rdt_cli/commands/social.py @@ -108,6 +108,41 @@ def save(id_or_index: str, undo: bool) -> None: exit_for_error(exc, prefix="Save failed") +# โ”€โ”€ delete โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + +@click.command() +@click.argument("id_or_index") +@click.option("-y", "--yes", is_flag=True, help="Skip confirmation prompt") +def delete(id_or_index: str, yes: bool) -> None: + """Delete your own post or comment (by ID or index number) + + Irreversible; only works on things submitted by the logged-in + account. A bare ID is assumed to be a post โ€” pass a t1_ fullname + to delete a comment. + + Examples: + rdt delete 3 # delete result #3 (asks to confirm) + rdt delete 1abc123 -y # delete a post by ID, no prompt + rdt delete t1_abc123 -y # delete a comment by fullname + """ + cred = require_auth() + fullname = _resolve_fullname(id_or_index) + if not fullname: + return + if not yes and not click.confirm(f"Delete {fullname}? This cannot be undone"): + console.print("[dim]Aborted.[/dim]") + return + try: + with RedditClient(cred) as client: + client.validate_session() + client.delete_item(fullname) + write_delay() + console.print(f"[green]โœ… Deleted[/green] {fullname}") + except RedditApiError as exc: + exit_for_error(exc, prefix="Delete failed") + + # โ”€โ”€ subscribe / unsubscribe โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ diff --git a/rdt_cli/constants.py b/rdt_cli/constants.py index 2bd613f..f91bc86 100644 --- a/rdt_cli/constants.py +++ b/rdt_cli/constants.py @@ -50,6 +50,7 @@ VOTE_URL = "/api/vote" SAVE_URL = "/api/save" UNSAVE_URL = "/api/unsave" +DEL_URL = "/api/del" SUBSCRIBE_URL = "/api/subscribe" COMMENT_URL = "/api/comment" SUBSCRIPTIONS_URL = "/subreddits/mine/subscriber.json" diff --git a/tests/test_cli.py b/tests/test_cli.py index 9efe4f7..4900fea 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -36,7 +36,7 @@ def test_all_commands_registered(self): "user-posts", "user-comments", "saved", "upvoted", "open", "read", "show", "search", "export", - "upvote", "save", "subscribe", "comment", + "upvote", "save", "subscribe", "comment", "delete", "post", ] for cmd in expected: @@ -70,7 +70,7 @@ class TestCommandHelp: "user-posts", "user-comments", "saved", "upvoted", "open", "read", "show", "search", "export", - "upvote", "save", "subscribe", "comment", + "upvote", "save", "subscribe", "comment", "delete", "post", ], ) @@ -719,6 +719,35 @@ def test_upvoted_mocked(self): result = runner.invoke(cli, ["upvoted", "--json"]) assert result.exit_code == 0 + def test_delete_mocked(self): + from unittest.mock import MagicMock + + from rdt_cli.auth import Credential + + cred = Credential(cookies={"reddit_session": "test"}, username="spez") + delete_mock = MagicMock(return_value={}) + with patch("rdt_cli.commands._common.get_credential", return_value=cred): + with patch("rdt_cli.client.RedditClient.validate_session", return_value={"authenticated": True}): + with patch("rdt_cli.client.RedditClient.delete_item", delete_mock): + with patch("rdt_cli.commands.social.write_delay"): + result = runner.invoke(cli, ["delete", "1abc123", "--yes"]) + assert result.exit_code == 0 + assert "Deleted" in result.output + delete_mock.assert_called_once_with("t3_1abc123") + + def test_delete_aborts_without_confirmation(self): + from unittest.mock import MagicMock + + from rdt_cli.auth import Credential + + cred = Credential(cookies={"reddit_session": "test"}, username="spez") + delete_mock = MagicMock(return_value={}) + with patch("rdt_cli.commands._common.get_credential", return_value=cred): + with patch("rdt_cli.client.RedditClient.delete_item", delete_mock): + result = runner.invoke(cli, ["delete", "1abc123"], input="n\n") + assert result.exit_code == 0 + delete_mock.assert_not_called() + # โ”€โ”€ Mocked subs-only feed โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€