import re from urllib.parse import urlparse import httpx from arcade.sdk import ToolContext from arcade.sdk.errors import ToolExecutionError from arcade_reddit.client import RedditClient from arcade_reddit.enums import RedditThingType def remove_none_values(data: dict) -> dict: """Remove all keys with None values from a dictionary""" return {k: v for k, v in data.items() if v is not None} def normalize_subreddit_name(subreddit: str) -> str: """Normalize a subreddit name""" return subreddit.lower().replace("r/", "").replace(" ", "") def _simplify_post_data(post_data: dict, include_body: bool = False) -> dict: simplified_data = { "id": post_data.get("id"), "name": post_data.get("name"), "title": post_data.get("title"), "author": post_data.get("author"), "subreddit": post_data.get("subreddit"), "created_utc": post_data.get("created_utc"), "num_comments": post_data.get("num_comments"), "score": post_data.get("score"), "upvote_ratio": post_data.get("upvote_ratio"), "upvotes": post_data.get("ups"), "permalink": post_data.get("permalink"), "url": post_data.get("url"), "is_video": post_data.get("is_video"), } if include_body: simplified_data["body"] = post_data.get("selftext") return simplified_data def parse_get_posts_in_subreddit_response(data: dict) -> dict: """Parse the response from the Reddit API for getting posts in a subreddit Associated Reddit API endpoints: https://www.reddit.com/dev/api/#GET_hot https://www.reddit.com/dev/api/#GET_new https://www.reddit.com/dev/api/#GET_rising https://www.reddit.com/dev/api/#GET_{sort} Args: data: The response from the Reddit API deserialized as a dictionary. NOTE: The response doesn't contain the body of the posts. Returns: A dictionary with a cursor for the next page and a list of posts """ posts = [] for child in data.get("data", {}).get("children", []): post_data = child.get("data", {}) post = _simplify_post_data(post_data) posts.append(post) result = {"cursor": data.get("data", {}).get("after"), "posts": posts} return result def parse_get_content_of_post_response(data: list) -> dict: """Parse the json representation of a Reddit post to get the content of a post Args: data: The json representation of a Reddit post (retrieved by appending .json to the permalink) Returns: A dictionary with the content of the post """ if not data or not isinstance(data, list) or len(data) == 0: return {} try: post_data = data[0].get("data", {}).get("children", [{}])[0].get("data", {}) return _simplify_post_data(post_data, include_body=True) except (IndexError, AttributeError, KeyError): return {} def parse_get_content_of_multiple_posts_response(data: dict) -> list[dict]: """Parse the json representation of multiple Reddit posts to get the content of each post Args: data: The json representation of multiple Reddit posts (retrieved from the /api/info.json endpoint) Returns: A dictionary with the simplified content of each post """ if not data or not isinstance(data, dict) or len(data) == 0: return [] result = [] for post in data.get("data", {}).get("children", []): post_data = post.get("data", {}) result.append(_simplify_post_data(post_data, include_body=True)) return result def parse_get_top_level_comments_response(data: list) -> dict: """Parse the json representation of a Reddit post to get the top-level comments Args: data: The json representation of a Reddit post Returns: A dictionary with a list of top-level comments """ try: comments_listing = data[1]["data"]["children"] except (IndexError, KeyError): return {"comments": [], "num_comments": 0} comments = [] for comment in comments_listing: if comment.get("kind") != RedditThingType.COMMENT.value: continue comment_data = comment.get("data", {}) comments.append({ "id": comment_data.get("id"), "author": comment_data.get("author"), "body": comment_data.get("body"), "score": comment_data.get("score"), "created_utc": comment_data.get("created_utc"), }) return {"comments": comments, "num_comments": len(comments)} def parse_api_comment_response(data: dict) -> dict: """Parse the response from the Reddit API's /api/comment endpoint Args: data: The response from the Reddit API deserialized as a dictionary Returns: A dictionary with the comment data """ result = { "created_utc": data.get("created_utc"), "name": data.get("name"), "parent_id": data.get("parent_id"), "permalink": data.get("permalink"), "subreddit": data.get("subreddit"), "subreddit_id": data.get("subreddit_id"), "subreddit_name_prefixed": data.get("subreddit_name_prefixed"), } return result def _extract_id_from_url(identifier: str, regex: str, error_msg: str) -> str: """ Extract an ID from a Reddit URL using the provided regular expression. Args: identifier: The URL string from which to extract the ID. regex: The regular expression pattern containing a capturing group for the ID. error_msg: The error message to use if no ID can be extracted. Returns: The extracted ID as a string. Raises: ToolExecutionError: If the URL is not a Reddit URL or the pattern does not match. """ parsed = urlparse(identifier) if not parsed.netloc.endswith("reddit.com"): raise ToolExecutionError( message=f"Expected a reddit URL, but got: {identifier}", developer_message="The identifier should be a valid Reddit URL.", ) match = re.search(regex, parsed.path) if not match: raise ToolExecutionError( message=f"Could not extract id from URL: {identifier}", developer_message=error_msg, ) return match.group(1) def _extract_id_from_permalink(identifier: str, regex: str, error_msg: str) -> str: """ Extract an ID from a Reddit permalink using the provided regular expression. Args: identifier: The permalink string from which to extract the ID. regex: The regular expression pattern containing a capturing group for the ID. error_msg: The error message to use if no ID can be extracted. Returns: The extracted ID as a string. Raises: ToolExecutionError: If the pattern does not match the permalink. """ match = re.search(regex, identifier) if not match: raise ToolExecutionError( message=f"Could not extract id from permalink: {identifier}", developer_message=error_msg, ) return match.group(1) def _get_post_id(identifier: str) -> str: """ Retrieve the post ID from various types of Reddit post identifiers. The identifier can be a Reddit URL to the post, a permalink for the post, a fullname for the post (starting with 't3_'), or a raw post ID. Args: identifier: The Reddit post identifier. Returns: The post ID as a string. Raises: ToolExecutionError: If the identifier does not contain a valid post ID. """ if identifier.startswith("http://") or identifier.startswith("https://"): return _extract_id_from_url( identifier, r"/comments/([A-Za-z0-9]+)", "The reddit URL does not contain a valid post id.", ) elif identifier.startswith("/r/"): return _extract_id_from_permalink( identifier, r"/comments/([A-Za-z0-9]+)", "The permalink does not contain a valid post id.", ) else: pattern = re.compile(r"^(t3_)?([A-Za-z0-9]+)$") match = pattern.match(identifier) if match: return match.group(2) raise ToolExecutionError( message=f"Invalid identifier: {identifier}", developer_message=( "The identifier should be a valid Reddit URL, permalink, fullname, or post id." ), ) def _get_comment_id(identifier: str) -> str: """ Retrieve the comment ID from various types of Reddit comment identifiers. The identifier can be a Reddit URL to the comment, a permalink for the comment, a fullname for the comment (starting with 't1_'), or a raw comment ID. Args: identifier: The Reddit comment identifier. Returns: The comment ID as a string. Raises: ToolExecutionError: If the identifier does not contain a valid comment ID. """ if identifier.startswith("http://") or identifier.startswith("https://"): return _extract_id_from_url( identifier, r"/comment/([A-Za-z0-9]+)", "The reddit URL does not contain a valid comment id.", ) elif identifier.startswith("/r/"): return _extract_id_from_permalink( identifier, r"/comment/([A-Za-z0-9]+)", "The permalink does not contain a valid comment id.", ) else: if identifier.startswith("t1_"): return identifier[3:] if re.fullmatch(r"[A-Za-z0-9]+", identifier): return identifier raise ToolExecutionError( message=f"Invalid identifier: {identifier}", developer_message=( "The identifier should be a valid Reddit URL, permalink, fullname, or comment id." ), ) def create_path_for_post(identifier: str) -> str: """ Create a path for a Reddit post. Args: identifier: The identifier of the post. The identifier may be a reddit URL, a permalink for the post, a fullname for the post, or a post id. Returns: The path for the post. """ if identifier.startswith("http://") or identifier.startswith("https://"): parsed = urlparse(identifier) if not parsed.netloc.endswith("reddit.com"): raise ToolExecutionError( message=f"Expected a reddit URL, but got: {identifier}", developer_message="The identifier should be a valid Reddit URL.", ) return parsed.path if identifier.startswith("/r/"): return identifier post_id = _get_post_id(identifier) return f"/comments/{post_id}" def create_fullname_for_post(identifier: str) -> str: """ Create a fullname for a Reddit post. Args: identifier: The identifier of the post. The identifier may be a reddit URL, a permalink for the post, a fullname for the post, or a post id. Returns: The fullname for the post. """ if identifier.startswith("t3_"): return identifier post_id = _get_post_id(identifier) return f"t3_{post_id}" def create_fullname_for_multiple_posts(post_identifiers: list[str]) -> tuple[list[str], list[dict]]: """ Create fullnames for multiple Reddit posts. Args: post_identifiers: A list of Reddit post identifiers. The identifiers may be reddit URLs, permalinks, fullnames, or post ids. Returns: (fullnames, warnings): A tuple of a list of fullnames for the posts and a list of warnings if any of the identifiers are invalid. """ fullnames = [] warnings = [] for identifier in post_identifiers: try: fullnames.append(create_fullname_for_post(identifier)) except ToolExecutionError: message = f"'{identifier}' is not a valid Reddit post identifier." warnings.append({"message": message, "identifier": identifier}) return fullnames, warnings def create_fullname_for_comment(identifier: str) -> str: """ Create a fullname for a Reddit comment. Args: identifier: The identifier of the comment. The identifier may be a reddit URL to the comment, a permalink for the comment, a fullname for the comment, or a comment id. Returns: The fullname for the comment. """ if identifier.startswith("t1_"): return identifier comment_id = _get_comment_id(identifier) return f"t1_{comment_id}" async def resolve_subreddit_access(client: RedditClient, subreddit: str) -> dict: """Checks whether the specified subreddit exists and is accessible. Helps abstract the logic of checking subreddit access. Args: client: The Reddit client subreddit: The subreddit to check Returns: A dictionary that specifies whether the subreddit exists and whether it is accessible to the user. """ normalized_name = normalize_subreddit_name(subreddit) try: await client.get(f"r/{normalized_name}/about.json") except httpx.HTTPStatusError as e: if e.response.status_code in (404, 302): return {"exists": False, "accessible": False} elif e.response.status_code == 403: return {"exists": True, "accessible": False} raise return {"exists": True, "accessible": True} def parse_subreddit_rules_response(data: dict) -> dict: """ Parse the response data from the Reddit API for subreddit rules. Args: data (dict): The raw API response containing subreddit rules. Returns: dict: A dictionary with a 'rules' key containing a list of parsed rules. """ rules = [] for rule in data.get("rules", []): rules.append({ "priority": rule.get("priority"), "title": rule.get("short_name"), "body": rule.get("description"), }) return {"rules": rules} async def parse_user_posts_response( context: ToolContext, posts_data: dict, include_body: bool ) -> dict: """Parse the response from the Reddit API for user posts Args: context: The tool context posts_data: The response from the Reddit API for getting the authenticated user's posts include_body: Whether to include the body of the posts in the parsed response Returns: A dictionary with a cursor for the next page (if there is one) and a list of posts """ next_cursor = posts_data.get("data", {}).get("after") parsed_response = {"cursor": next_cursor} if next_cursor else {} if not include_body: posts = [] for child in posts_data.get("data", {}).get("children", []): post_data = child.get("data", {}) simplified = _simplify_post_data(post_data, include_body=False) posts.append(simplified) parsed_response["posts"] = posts else: post_ids = [] for child in posts_data.get("data", {}).get("children", []): post_data = child.get("data", {}) identifier = post_data.get("name") or post_data.get("id") if identifier: post_ids.append(identifier) # Dynamically import get_content_of_multiple_posts to avoid circular dependency from arcade_reddit.tools.read import get_content_of_multiple_posts content_response = await get_content_of_multiple_posts( context=context, post_identifiers=post_ids ) posts_with_body = content_response.get("posts", []) parsed_response["posts"] = posts_with_body return parsed_response