From 0b7ebfb027b8e824e87996847d4d309b1048d0ed Mon Sep 17 00:00:00 2001 From: BoYanZh Date: Tue, 18 Aug 2026 05:41:50 -0700 Subject: [PATCH] feat: add custom feeds (multireddits) management and browsing support --- README.md | 10 +++ SKILL.md | 13 ++- rdt_cli/cli.py | 6 ++ rdt_cli/client.py | 86 +++++++++++++++++++ rdt_cli/commands/browse.py | 167 +++++++++++++++++++++++++++++++++++++ tests/test_cli.py | 134 +++++++++++++++++++++++++++++ 6 files changed, 415 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 4373ace..2d83a9d 100644 --- a/README.md +++ b/README.md @@ -85,6 +85,8 @@ rdt user-posts spez # User's submitted posts rdt user-comments spez # User's comments rdt saved # Your saved posts/items rdt upvoted # Your upvoted posts +rdt custom-feeds # List your custom feeds (multis) +rdt custom-feed dev -s hot # Browse posts from custom feed # Short index works after list commands (feed/popular/sub/search) rdt sub python @@ -120,6 +122,14 @@ 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 + +# ─── Custom Feeds (require login) ───────────── +rdt custom-feeds # List your custom feeds +rdt custom-feed dev -s hot # Browse posts in custom feed +rdt custom-feed-add dev golang # Add r/golang to custom feed +rdt custom-feed-remove dev golang # Remove r/golang from custom feed +rdt custom-feed-create tech -s rust # Create custom feed +rdt custom-feed-delete tech -y # Delete custom feed ``` ## Authentication diff --git a/SKILL.md b/SKILL.md index 512e2c3..56ed5ac 100644 --- a/SKILL.md +++ b/SKILL.md @@ -92,6 +92,8 @@ Payloads live under `.data`. | `rdt user-comments ` | View user's comments | `rdt user-comments spez -n 5 --json` | | `rdt saved` | View your saved items | `rdt saved -n 10 --json` | | `rdt upvoted` | View your upvoted posts | `rdt upvoted -n 10 --json` | +| `rdt custom-feeds` | List your custom feeds (multis) | `rdt custom-feeds --json` | +| `rdt custom-feed ` | Browse posts from custom feed | `rdt custom-feed dev -s hot -n 10` | | `rdt open ` | Open post in browser | `rdt open 3` | ### Reading @@ -124,7 +126,16 @@ Payloads live under `.data`. | `rdt save --undo` | Unsave | `rdt save 3 --undo` | | `rdt subscribe ` | Subscribe | `rdt subscribe python` | | `rdt subscribe --undo` | Unsubscribe | `rdt subscribe python --undo` | -| `rdt comment ` | Post a comment | `rdt comment 3 "Great post!"` | +### Custom Feeds (require auth) + +| Command | Description | Example | +|---------|-------------|---------| +| `rdt custom-feeds` | List your custom feeds (multis) | `rdt custom-feeds --json` | +| `rdt custom-feed ` | Browse posts from custom feed | `rdt custom-feed dev -s hot -n 10` | +| `rdt custom-feed-add ` | Add subreddit to custom feed | `rdt custom-feed-add dev golang` | +| `rdt custom-feed-remove ` | Remove subreddit from custom feed | `rdt custom-feed-remove dev golang` | +| `rdt custom-feed-create ` | Create a new custom feed | `rdt custom-feed-create tech -s python,rust` | +| `rdt custom-feed-delete ` | Delete a custom feed | `rdt custom-feed-delete tech -y` | ### Account diff --git a/rdt_cli/cli.py b/rdt_cli/cli.py index 2a3cf55..a815f47 100644 --- a/rdt_cli/cli.py +++ b/rdt_cli/cli.py @@ -52,6 +52,12 @@ def cli(ctx: click.Context, verbose: bool) -> None: cli.add_command(browse.user_comments) cli.add_command(browse.saved) cli.add_command(browse.upvoted) +cli.add_command(browse.custom_feeds) +cli.add_command(browse.custom_feed) +cli.add_command(browse.custom_feed_add) +cli.add_command(browse.custom_feed_remove) +cli.add_command(browse.custom_feed_create) +cli.add_command(browse.custom_feed_delete) cli.add_command(browse.open_post) # ─── Post commands ─────────────────────────────────────────────────── diff --git a/rdt_cli/client.py b/rdt_cli/client.py index 7762976..1044e37 100644 --- a/rdt_cli/client.py +++ b/rdt_cli/client.py @@ -2,6 +2,7 @@ from __future__ import annotations +import json import logging from typing import Any @@ -133,6 +134,14 @@ def _post(self, url: str, data: dict[str, Any] | None = None) -> Any: """POST request.""" return self._write_request("POST", url, data=data) + def _put(self, url: str, data: dict[str, Any] | None = None) -> Any: + """PUT request.""" + return self._write_request("PUT", url, data=data) + + def _delete(self, url: str, data: dict[str, Any] | None = None, params: dict[str, Any] | None = None) -> Any: + """DELETE request.""" + return self._write_request("DELETE", url, data=data, params=params) + # ── Listing helpers ───────────────────────────────────────────── @staticmethod @@ -417,3 +426,80 @@ def get_subs_only_feed( ) return {"data": {"children": all_posts, "after": None}} + + # ── Custom feeds (multireddits) ───────────────────────────────── + + def get_custom_feeds(self) -> list[dict]: + """Get user's custom feeds (multireddits).""" + data = self._get("/api/multi/mine.json", params={"raw_json": 1}) + if isinstance(data, list): + return data + return [] + + def get_custom_feed( + self, + feed_name: str, + username: str | None = None, + sort: str = "hot", + limit: int = DEFAULT_LIMIT, + after: str | None = None, + time_filter: str | None = None, + ) -> dict: + """Get listing for a custom feed (multireddit).""" + user = username or self.session.username or "me" + url = ( + f"/user/{user}/m/{feed_name}.json" + if sort == "hot" + else f"/user/{user}/m/{feed_name}/{sort}.json" + ) + params: dict[str, Any] = {"limit": limit, "raw_json": 1} + if after: + params["after"] = after + if time_filter and sort in ("top", "controversial"): + params["t"] = time_filter + return self._get(url, params=params) + + def add_to_custom_feed( + self, feed_name: str, subreddit: str, username: str | None = None, + ) -> dict: + """Add a subreddit to a custom feed (multireddit).""" + user = username or self.session.username or "me" + url = f"/api/multi/user/{user}/m/{feed_name}/r/{subreddit}" + return self._put(url, data={"model": json.dumps({"name": subreddit})}) + + def remove_from_custom_feed( + self, feed_name: str, subreddit: str, username: str | None = None, + ) -> dict: + """Remove a subreddit from a custom feed (multireddit).""" + user = username or self.session.username or "me" + url = f"/api/multi/user/{user}/m/{feed_name}/r/{subreddit}" + return self._delete(url) + + def create_custom_feed( + self, + feed_name: str, + display_name: str | None = None, + description: str = "", + visibility: str = "private", + subreddits: list[str] | None = None, + username: str | None = None, + ) -> dict: + """Create or update a custom feed (multireddit).""" + user = username or self.session.username or "me" + url = f"/api/multi/user/{user}/m/{feed_name}" + sub_list = [{"name": s} for s in (subreddits or [])] + model = { + "display_name": display_name or feed_name, + "description_md": description, + "visibility": visibility, + "subreddits": sub_list, + } + return self._post(url, data={"model": json.dumps(model)}) + + def delete_custom_feed( + self, feed_name: str, username: str | None = None, + ) -> dict: + """Delete an entire custom feed (multireddit).""" + user = username or self.session.username or "me" + url = f"/api/multi/user/{user}/m/{feed_name}" + return self._delete(url) diff --git a/rdt_cli/commands/browse.py b/rdt_cli/commands/browse.py index 069bbae..59f6945 100644 --- a/rdt_cli/commands/browse.py +++ b/rdt_cli/commands/browse.py @@ -444,6 +444,173 @@ def upvoted( ) +# ── custom-feeds / custom-feed ────────────────────────────────────── + + +@click.command(name="custom-feeds") +@structured_output_options +def custom_feeds(as_json: bool, as_yaml: bool) -> None: + """List your custom feeds (multireddits)""" + from ._common import exit_for_error, run_client_action + + cred = require_auth() + + def _render(raw_data: list) -> None: + table = Table(title="📑 Custom Feeds", show_lines=True) + table.add_column("Name", style="bold cyan") + table.add_column("Display Name", style="green") + table.add_column("Subreddits", style="dim") + table.add_column("Path", style="dim") + + for item in raw_data: + d = item.get("data", {}) + name = d.get("name", "") + display_name = d.get("display_name", "") + subs = [s.get("name", "") for s in d.get("subreddits", [])] + sub_str = f"{len(subs)} subs ({', '.join(subs[:5])}{'...' if len(subs) > 5 else ''})" + path = d.get("path", "") + table.add_row(name, display_name, sub_str, path) + + console.print(table) + console.print("\n [dim]💡 Use [bold]rdt custom-feed [/bold] to browse a custom feed[/dim]") + + try: + raw_list = run_client_action(cred, lambda c: c.get_custom_feeds()) + clean_data = [ + { + "name": it.get("data", {}).get("name"), + "display_name": it.get("data", {}).get("display_name"), + "path": it.get("data", {}).get("path"), + "subreddits": [s.get("name") for s in it.get("data", {}).get("subreddits", [])], + } + for it in raw_list + ] + if maybe_print_structured(clean_data, as_json=as_json, as_yaml=as_yaml): + return + _render(raw_list) + except Exception as exc: + exit_for_error(exc, as_json=as_json, as_yaml=as_yaml) + + +@click.command(name="custom-feed") +@click.argument("name") +@click.option("-s", "--sort", default="hot", type=click.Choice(SORT_OPTIONS), help="Sort order") +@click.option("-t", "--time", "time_filter", default=None, type=click.Choice(TIME_FILTERS), help="Time filter") +@click.option("-n", "--limit", default=25, type=int, help="Number of posts (max 100)") +@click.option("--after", default=None, help="Pagination cursor") +@listing_options +def custom_feed( + name: str, + sort: str, time_filter: str | None, limit: int, after: str | None, + as_json: bool, as_yaml: bool, + output_file: str | None, full_text: bool, compact: bool, +) -> None: + """Browse posts from a custom feed (multireddit)""" + cred = require_auth() + _handle_listing( + cred, + action=lambda c: c.get_custom_feed( + feed_name=name, + sort=sort, + limit=limit, + after=after, + time_filter=time_filter, + ), + data_title=f"📑 Custom Feed: {name} ({sort})", + next_cmd=f"rdt custom-feed {name} -s {sort}", + as_json=as_json, as_yaml=as_yaml, + output_file=output_file, full_text=full_text, compact=compact, + ) + + +@click.command(name="custom-feed-add") +@click.argument("name") +@click.argument("subreddit") +def custom_feed_add(name: str, subreddit: str) -> None: + """Add a subreddit to a custom feed (multireddit)""" + from ._common import exit_for_error, write_delay + + cred = require_auth() + try: + with RedditClient(cred) as client: + client.validate_session() + client.add_to_custom_feed(name, subreddit) + write_delay() + console.print(f"[green]✅ Added[/green] r/{subreddit} to custom feed [bold cyan]{name}[/bold cyan]") + except Exception as exc: + exit_for_error(exc, prefix="Failed to add to custom feed") + + +@click.command(name="custom-feed-remove") +@click.argument("name") +@click.argument("subreddit") +def custom_feed_remove(name: str, subreddit: str) -> None: + """Remove a subreddit from a custom feed (multireddit)""" + from ._common import exit_for_error, write_delay + + cred = require_auth() + try: + with RedditClient(cred) as client: + client.validate_session() + client.remove_from_custom_feed(name, subreddit) + write_delay() + console.print(f"[green]✅ Removed[/green] r/{subreddit} from custom feed [bold cyan]{name}[/bold cyan]") + except Exception as exc: + exit_for_error(exc, prefix="Failed to remove from custom feed") + + +@click.command(name="custom-feed-create") +@click.argument("name") +@click.option("--display-name", default=None, help="Display name of custom feed") +@click.option("--description", default="", help="Description text") +@click.option("--public", "visibility", flag_value="public", help="Make feed public") +@click.option("--private", "visibility", flag_value="private", default=True, help="Make feed private (default)") +@click.option("-s", "--subreddits", default="", help="Comma-separated list of initial subreddits") +def custom_feed_create( + name: str, display_name: str | None, description: str, visibility: str, subreddits: str +) -> None: + """Create a new custom feed (multireddit)""" + from ._common import exit_for_error, write_delay + + cred = require_auth() + sub_list = [s.strip() for s in subreddits.split(",") if s.strip()] + try: + with RedditClient(cred) as client: + client.validate_session() + client.create_custom_feed( + feed_name=name, + display_name=display_name, + description=description, + visibility=visibility, + subreddits=sub_list, + ) + write_delay() + console.print(f"[green]✅ Created custom feed[/green] [bold cyan]{name}[/bold cyan] ({visibility})") + except Exception as exc: + exit_for_error(exc, prefix="Failed to create custom feed") + + +@click.command(name="custom-feed-delete") +@click.argument("name") +@click.option("-y", "--yes", is_flag=True, help="Skip confirmation prompt") +def custom_feed_delete(name: str, yes: bool) -> None: + """Delete a custom feed (multireddit)""" + from ._common import exit_for_error, write_delay + + cred = require_auth() + if not yes and not click.confirm(f"Are you sure you want to delete custom feed '{name}'?"): + console.print("[dim]Cancelled[/dim]") + return + try: + with RedditClient(cred) as client: + client.validate_session() + client.delete_custom_feed(name) + write_delay() + console.print(f"[green]✅ Deleted custom feed[/green] [bold cyan]{name}[/bold cyan]") + except Exception as exc: + exit_for_error(exc, prefix="Failed to delete custom feed") + + # ── open ──────────────────────────────────────────────────────────── diff --git a/tests/test_cli.py b/tests/test_cli.py index 50e7df2..c495dfd 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -972,3 +972,137 @@ def test_show_help_shows_compact(self): assert result.exit_code == 0 assert "--compact" in result.output + +class TestCustomFeeds: + """Tests for custom-feeds and custom-feed commands.""" + + def _mock_custom_feeds(self): + return [ + { + "data": { + "name": "dev", + "display_name": "Dev", + "path": "/user/example_user/m/dev", + "subreddits": [{"name": "python"}, {"name": "rust"}], + } + } + ] + + def _mock_listing(self): + return { + "data": { + "children": [ + { + "data": { + "id": "abc123", + "title": "Custom Feed Post", + "subreddit": "python", + "author": "dev_user", + "score": 42, + "num_comments": 5, + "created_utc": 1700000000.0, + "permalink": "/r/python/comments/abc123/test/", + "url": "https://reddit.com/r/python/comments/abc123/test/", + "is_self": True, + "selftext": "Content", + "over_18": False, + "is_video": False, + "stickied": False, + } + } + ], + "after": None, + } + } + + def test_custom_feeds_json(self): + from rdt_cli.auth import Credential + cred = Credential(cookies={"reddit_session": "test"}) + with patch("rdt_cli.auth.get_credential", return_value=cred): + with patch("rdt_cli.client.RedditClient.get_custom_feeds", return_value=self._mock_custom_feeds()): + result = runner.invoke(cli, ["custom-feeds", "--json"]) + assert result.exit_code == 0 + data = json.loads(result.output) + assert data["ok"] is True + assert len(data["data"]) == 1 + assert data["data"][0]["name"] == "dev" + assert data["data"][0]["subreddits"] == ["python", "rust"] + + def test_custom_feed_json(self): + from rdt_cli.auth import Credential + cred = Credential(cookies={"reddit_session": "test"}) + with patch("rdt_cli.auth.get_credential", return_value=cred): + with patch("rdt_cli.client.RedditClient.get_custom_feed", return_value=self._mock_listing()): + result = runner.invoke(cli, ["custom-feed", "dev", "--json"]) + assert result.exit_code == 0 + data = json.loads(result.output) + assert data["ok"] is True + assert data["data"]["data"]["children"][0]["data"]["title"] == "Custom Feed Post" + + def test_custom_feed_compact_json(self): + from rdt_cli.auth import Credential + cred = Credential(cookies={"reddit_session": "test"}) + with patch("rdt_cli.auth.get_credential", return_value=cred): + with patch("rdt_cli.client.RedditClient.get_custom_feed", return_value=self._mock_listing()): + result = runner.invoke(cli, ["custom-feed", "dev", "--compact", "--json"]) + assert result.exit_code == 0 + data = json.loads(result.output) + assert data["ok"] is True + assert len(data["data"]) == 1 + assert data["data"][0]["title"] == "Custom Feed Post" + + def test_custom_feed_add(self): + from rdt_cli.auth import Credential + cred = Credential(cookies={"reddit_session": "test"}) + with patch("rdt_cli.auth.get_credential", return_value=cred): + with patch("rdt_cli.client.RedditClient.validate_session", return_value={"authenticated": True}): + with patch("rdt_cli.client.RedditClient.add_to_custom_feed", return_value={}) as mock_add: + with patch("rdt_cli.commands._common.write_delay"): + result = runner.invoke(cli, ["custom-feed-add", "dev", "golang"]) + assert result.exit_code == 0 + assert "Added" in result.output + mock_add.assert_called_once_with("dev", "golang") + + def test_custom_feed_remove(self): + from rdt_cli.auth import Credential + cred = Credential(cookies={"reddit_session": "test"}) + with patch("rdt_cli.auth.get_credential", return_value=cred): + with patch("rdt_cli.client.RedditClient.validate_session", return_value={"authenticated": True}): + with patch("rdt_cli.client.RedditClient.remove_from_custom_feed", return_value={}) as mock_rm: + with patch("rdt_cli.commands._common.write_delay"): + result = runner.invoke(cli, ["custom-feed-remove", "dev", "golang"]) + assert result.exit_code == 0 + assert "Removed" in result.output + mock_rm.assert_called_once_with("dev", "golang") + + def test_custom_feed_create(self): + from rdt_cli.auth import Credential + cred = Credential(cookies={"reddit_session": "test"}) + with patch("rdt_cli.auth.get_credential", return_value=cred): + with patch("rdt_cli.client.RedditClient.validate_session", return_value={"authenticated": True}): + with patch("rdt_cli.client.RedditClient.create_custom_feed", return_value={}) as mock_create: + with patch("rdt_cli.commands._common.write_delay"): + result = runner.invoke(cli, ["custom-feed-create", "tech", "--subreddits", "python,rust"]) + assert result.exit_code == 0 + assert "Created custom feed" in result.output + mock_create.assert_called_once_with( + feed_name="tech", + display_name=None, + description="", + visibility="private", + subreddits=["python", "rust"], + ) + + def test_custom_feed_delete(self): + from rdt_cli.auth import Credential + cred = Credential(cookies={"reddit_session": "test"}) + with patch("rdt_cli.auth.get_credential", return_value=cred): + with patch("rdt_cli.client.RedditClient.validate_session", return_value={"authenticated": True}): + with patch("rdt_cli.client.RedditClient.delete_custom_feed", return_value={}) as mock_del: + with patch("rdt_cli.commands._common.write_delay"): + result = runner.invoke(cli, ["custom-feed-delete", "tech", "-y"]) + assert result.exit_code == 0 + assert "Deleted custom feed" in result.output + mock_del.assert_called_once_with("tech") + +