reddit-post-service.tsā¢23.6 kB
/**
 * @file Reddit post and comment service
 * @module services/reddit/reddit-post-service
 * 
 * @remarks
 * This service handles all post and comment-related operations for the Reddit API.
 * It extends RedditFetchService to inherit common fetching functionality and adds
 * specific methods for creating, fetching, and interacting with posts and comments.
 * 
 * The service handles:
 * - Fetching posts with various sort options
 * - Creating new posts (text, link, image, video, gallery)
 * - Sending comments and replies
 * - Fetching individual posts and comments
 * - Managing notifications and messages
 * - Searching Reddit content
 * 
 * @see {@link https://www.reddit.com/dev/api/#section_listings} Reddit Listings API
 * @see {@link https://www.reddit.com/dev/api/#section_links_and_comments} Reddit Links & Comments API
 */
import { DEFAULT_POST_LIMIT, DEFAULT_NOTIFICATION_LIMIT } from '../../constants.js';
import type { RedditMessageParams, RedditMessageResponse ,
  RedditPost,
  RedditPostParams,
  RedditPostResponse,
  RedditApiResponse,
  FetchPostsOptions,
  RedditCommentThread,
  RedditPostWithComments,
  RedditNotification,
  FetchNotificationsOptions,
  RedditComment} from '../../types/reddit.js';
import { RedditError 
} from '../../types/reddit.js';
import {
  transformPost,
  transformComment,
  transformNotification,
} from '../../utils/reddit-transformers.js';
import type { RedditAuthService } from './reddit-auth-service.js';
import { RedditFetchService } from './reddit-fetch-service.js';
/**
 * Service for handling Reddit post and comment operations
 * 
 * @remarks
 * This service extends RedditFetchService to provide specialized methods
 * for working with Reddit posts, comments, and user interactions.
 * All methods include automatic rate limiting and error handling.
 * 
 * @example
 * ```typescript
 * const postService = new RedditPostService(
 *   'https://oauth.reddit.com',
 *   authService,
 *   2000 // 2 second rate limit
 * );
 * 
 * const posts = await postService.fetchPosts({
 *   subreddit: 'programming',
 *   sort: 'hot',
 *   limit: 25
 * });
 * ```
 */
export class RedditPostService extends RedditFetchService {
  /**
   * Creates a new Reddit post service
   * 
   * @param baseUrl - The base URL for Reddit API requests (https://oauth.reddit.com)
   * @param authService - The authentication service instance
   * @param rateLimitDelay - Delay in milliseconds between API requests
   */
  constructor(baseUrl: string, authService: RedditAuthService, rateLimitDelay: number) {
    super(baseUrl, authService, rateLimitDelay);
  }
  /**
   * Fetches posts from Reddit based on the provided options
   * 
   * @param options - Options for fetching posts
   * @param options.sort - Sort order: 'hot', 'new', or 'controversial'
   * @param options.limit - Maximum number of posts to fetch (default: 25)
   * @param options.subreddit - Subreddit to fetch from (optional)
   * @returns Array of Reddit posts
   * @throws {RedditError} Thrown if sort option is invalid or API request fails
   * 
   * @example
   * ```typescript
   * // Fetch hot posts from a specific subreddit
   * const posts = await postService.fetchPosts({
   *   subreddit: 'typescript',
   *   sort: 'hot',
   *   limit: 50
   * });
   * 
   * // Fetch new posts from home feed
   * const homePosts = await postService.fetchPosts({
   *   sort: 'new',
   *   limit: 25
   * });
   * ```
   */
  public async fetchPosts(
    options: FetchPostsOptions = { sort: "hot", subreddit: "" },
  ): Promise<RedditPost[]> {
    const { sort = "hot", limit = DEFAULT_POST_LIMIT, subreddit } = options;
    switch (sort) {
      case "hot":
        return this.getHotPosts(subreddit, limit);
      case "new":
        return this.getNewPosts(subreddit, limit);
      case "controversial":
        return this.getControversialPosts(subreddit, limit);
      default:
        throw new RedditError(`Invalid sort option: ${sort}`, "VALIDATION_ERROR");
    }
  }
  /**
   * Formats a subreddit name by removing the r/ prefix if present
   * 
   * @param subreddit - The subreddit name to format
   * @returns Formatted subreddit name without r/ prefix
   * @internal
   */
  private formatSubreddit(subreddit?: string): string {
    if (!subreddit) {return "";}
    return subreddit.replace(/^r\//, "");
  }
  /**
   * Fetches hot posts from Reddit
   * 
   * @param subreddit - Optional subreddit to fetch from
   * @param limit - Maximum number of posts to fetch
   * @returns Array of hot posts
   * @throws {RedditError} Thrown if API request fails
   * @internal
   */
  private async getHotPosts(subreddit?: string, limit: number = 10): Promise<RedditPost[]> {
    const formattedSubreddit = this.formatSubreddit(subreddit);
    const endpoint = formattedSubreddit
      ? `/r/${formattedSubreddit}/hot.json?limit=${limit}`
      : `/hot.json?limit=${limit}`;
    const data = await this.redditFetch<RedditApiResponse<RedditPost>>(endpoint);
    return data.data.children?.map((child) => transformPost(child.data)) ?? [];
  }
  /**
   * Fetches new posts from Reddit
   * 
   * @param subreddit - Optional subreddit to fetch from
   * @param limit - Maximum number of posts to fetch
   * @returns Array of new posts sorted by creation time
   * @throws {RedditError} Thrown if API request fails
   * @internal
   */
  private async getNewPosts(subreddit?: string, limit: number = 10): Promise<RedditPost[]> {
    const formattedSubreddit = this.formatSubreddit(subreddit);
    const endpoint = formattedSubreddit
      ? `/r/${formattedSubreddit}/new.json?limit=${limit}`
      : `/new.json?limit=${limit}`;
    const data = await this.redditFetch<RedditApiResponse<RedditPost>>(endpoint);
    return data.data.children?.map((child) => transformPost(child.data)) ?? [];
  }
  /**
   * Fetches controversial posts from Reddit
   * 
   * @param subreddit - Optional subreddit to fetch from
   * @param limit - Maximum number of posts to fetch
   * @returns Array of controversial posts
   * @throws {RedditError} Thrown if API request fails
   * @internal
   */
  private async getControversialPosts(
    subreddit?: string,
    limit: number = 10,
  ): Promise<RedditPost[]> {
    const formattedSubreddit = this.formatSubreddit(subreddit);
    const endpoint = formattedSubreddit
      ? `/r/${formattedSubreddit}/controversial.json?limit=${limit}`
      : `/controversial.json?limit=${limit}`;
    const data = await this.redditFetch<RedditApiResponse<RedditPost>>(endpoint);
    return data.data.children?.map((child) => transformPost(child.data)) ?? [];
  }
  /**
   * Creates a new post on Reddit
   * 
   * @param params - Parameters for creating the post
   * @param params.subreddit - The subreddit to post in (without r/ prefix)
   * @param params.title - The post title (1-300 characters)
   * @param params.content - The post content (text/markdown)
   * @returns Response containing the created post details
   * @throws {RedditError} Thrown if required fields are missing or API request fails
   * 
   * @remarks
   * This method currently only supports text posts. For link, image, video,
   * or gallery posts, additional parameters would need to be implemented.
   * 
   * @example
   * ```typescript
   * const post = await postService.createPost({
   *   subreddit: 'test',
   *   title: 'My First Post',
   *   content: 'This is the post content'
   * });
   * ```
   */
  public async createPost(params: RedditPostParams): Promise<RedditPostResponse> {
    const { subreddit, title, content } = params;
    if (!subreddit || !title || !content) {
      throw new RedditError("Missing required fields", "VALIDATION_ERROR");
    }
    const formData = new URLSearchParams();
    formData.append("sr", subreddit);
    formData.append("title", title);
    formData.append("text", content);
    const response = await this.redditFetch<RedditPostResponse>("/api/submit", {
      method: "POST",
      body: formData,
    });
    return response;
  }
  /**
   * Fetches a single Reddit post by ID, including its comments
   * 
   * @param id - The post ID (with or without t3_ prefix)
   * @returns The post with its comment tree
   * @throws {RedditError} Thrown if post not found or API request fails
   * 
   * @remarks
   * This method fetches both the post metadata and its comment tree.
   * Comments are returned as a nested structure preserving the thread hierarchy.
   * 
   * @example
   * ```typescript
   * const post = await postService.fetchPostById('abc123');
   * console.log(post.title);
   * console.log(`${post.comments.length} top-level comments`);
   * 
   * // Access nested replies
   * post.comments.forEach(thread => {
   *   console.log(thread.comment.body);
   *   console.log(`${thread.replies.length} replies`);
   * });
   * ```
   */
  public async fetchPostById(id: string): Promise<RedditPostWithComments> {
    try {
      // Reddit API requires the post ID to be prefixed with t3_
      const formattedId = id.startsWith("t3_") ? id : `t3_${id}`;
      // First, fetch the post info
      const postEndpoint = `/api/info.json?id=${formattedId}`;
      const postData = await this.redditFetch<RedditApiResponse<RedditPost>>(postEndpoint);
      if (!postData.data.children || postData.data.children.length === 0) {
        throw new RedditError(`Post with ID ${id} not found`, "API_ERROR");
      }
      const post = transformPost(postData.data.children[0].data);
      // Then fetch the comments
      const rawid = id.replace("t3_", "");
      const commentsEndpoint = `/comments/${rawid}.json`;
      const commentsData = await this.redditFetch<any[]>(commentsEndpoint);
      // Reddit returns an array with 2 elements: [0] = post data, [1] = comments data
      if (!commentsData || commentsData.length < 2) {
        // Return post without comments if comments data is missing
        return {
          ...post,
          comments: [],
        };
      }
      // Process the comment tree
      const commentListing = commentsData[1].data.children;
      const comments = this.processCommentTree(commentListing);
      return {
        ...post,
        comments,
      };
    } catch (error) {
      throw new RedditError(
        `Failed to fetch post: ${error instanceof Error ? error.message : "Unknown error"}`,
        "API_ERROR",
        error,
      );
    }
  }
  /**
   * Recursively processes a Reddit comment tree into a structured format
   * 
   * @param commentListing - Raw comment data from Reddit API
   * @returns Array of comment threads with nested replies
   * 
   * @remarks
   * This method recursively processes the comment tree structure returned by Reddit.
   * Each comment can have replies, which can have their own replies, etc.
   * The method preserves this hierarchical structure.
   * 
   * @internal
   */
  private processCommentTree(commentListing: any[]): RedditCommentThread[] {
    if (!commentListing || !Array.isArray(commentListing)) {
      return [];
    }
    return commentListing
      .filter((item) => item.kind === "t1")
      .map((item) => {
        const comment = transformComment(item.data);
        // Process replies if they exist
        let replies: RedditCommentThread[] = [];
        if (
          item.data.replies &&
          typeof item.data.replies === "object" &&
          item.data.replies.data &&
          item.data.replies.data.children
        ) {
          replies = this.processCommentTree(item.data.replies.data.children);
        }
        return {
          comment,
          replies,
        };
      });
  }
  /**
   * Fetches user notifications from Reddit inbox
   * 
   * @param options - Options for filtering and paginating notifications
   * @param options.filter - Filter type: 'all', 'unread', 'messages', 'comments', 'mentions'
   * @param options.limit - Maximum number of notifications to fetch (default: 25)
   * @param options.after - Pagination cursor for next page
   * @param options.before - Pagination cursor for previous page
   * @param options.markRead - Whether to mark fetched notifications as read
   * @returns Array of notifications
   * @throws {RedditError} Thrown if API request fails
   * 
   * @example
   * ```typescript
   * // Get all unread notifications
   * const unread = await postService.fetchNotifications({
   *   filter: 'unread',
   *   limit: 50
   * });
   * 
   * // Get only comment replies
   * const comments = await postService.fetchNotifications({
   *   filter: 'comments',
   *   limit: 25
   * });
   * ```
   */
  public async fetchNotifications(
    options: FetchNotificationsOptions = {},
  ): Promise<RedditNotification[]> {
    try {
      const filter = options.filter || "all";
      const limit = options.limit || DEFAULT_NOTIFICATION_LIMIT;
      // Determine the endpoint based on the filter
      let endpoint = "";
      switch (filter) {
        case "unread":
          endpoint = "/message/unread.json";
          break;
        case "messages":
          endpoint = "/message/messages.json";
          break;
        case "comments":
          endpoint = "/message/comments.json";
          break;
        case "mentions":
          endpoint = "/message/mentions.json";
          break;
        case "all":
        default:
          endpoint = "/message/inbox.json";
          break;
      }
      // Add parameters
      const params = new URLSearchParams();
      params.append("limit", limit.toString());
      if (options.after) {params.append("after", options.after);}
      if (options.before) {params.append("before", options.before);}
      endpoint += `?${params.toString()}`;
      const data = await this.redditFetch<RedditApiResponse<any>>(endpoint);
      if (!data.data.children) {
        return [];
      }
      let notifications = data.data.children.map((item) => transformNotification(item.data));
      // Apply client-side filters
      if (options.excludeIds?.length) {
        notifications = notifications.filter((n) => !options.excludeIds?.includes(n.id));
      }
      if (options.excludeTypes?.length) {
        notifications = notifications.filter((n) => !options.excludeTypes?.includes(n.type));
      }
      if (options.excludeSubreddits?.length) {
        notifications = notifications.filter(
          (n) => !n.subreddit || !options.excludeSubreddits?.includes(n.subreddit),
        );
      }
      if (options.markRead && notifications.length > 0 && filter !== "unread") {
        const ids = notifications.filter((n) => n.isNew).map((n) => n.id);
        if (ids.length > 0) {
          await this.markMessagesRead(ids);
        }
      }
      return notifications;
    } catch (error) {
      throw new RedditError(
        `Failed to fetch notifications: ${error instanceof Error ? error.message : "Unknown error"}`,
        "API_ERROR",
        error,
      );
    }
  }
  public async deleteMessage(id: string): Promise<void> {
    try {
      const formData = new URLSearchParams({
        id,
      });
      await this.redditFetch("/api/del_msg", {
        method: "POST",
        body: formData,
      });
    } catch (error) {
      throw new RedditError(
        `Failed to delete message: ${error instanceof Error ? error.message : "Unknown error"}`,
        "API_ERROR",
        error,
      );
    }
  }
  private async markMessagesRead(ids: string[]): Promise<void> {
    const formData = new URLSearchParams({
      id: ids.join(","),
    });
    await this.redditFetch("/api/read_message", {
      method: "POST",
      body: formData,
    });
  }
  /**
   * Fetches a single comment by its ID
   * @param id - The ID of the comment to fetch
   * @returns Promise<RedditComment>
   */
  public async fetchCommentById(id: string): Promise<RedditComment> {
    try {
      // Reddit API requires the comment ID to be prefixed with t1_
      const formattedId = id.startsWith("t1_") ? id : `t1_${id}`;
      const endpoint = `/api/info.json?id=${formattedId}`;
      const response = await this.redditFetch<RedditApiResponse<RedditComment>>(endpoint);
      if (!response.data.children || response.data.children.length === 0) {
        throw new RedditError(`Comment with ID ${id} not found`, "API_ERROR");
      }
      return transformComment(response.data.children[0].data);
    } catch (error) {
      throw new RedditError(
        `Failed to fetch comment: ${error instanceof Error ? error.message : "Unknown error"}`,
        "API_ERROR",
        error,
      );
    }
  }
  /**
   * Fetches a comment thread (comment with all its replies) by the comment ID and post ID
   * @param id - The ID of the post containing the comment
   * @param id - The ID of the comment to fetch with its replies
   * @returns Promise<RedditCommentThread>
   */
  public async fetchCommentThread(parentId: string, id: string): Promise<RedditCommentThread> {
    try {
      // Reddit API endpoint for fetching a specific comment thread
      const endpoint = `/comments/${parentId.replace("t3_", "")}/comment/${id.replace("t1_", "")}.json`;
      const response = await this.redditFetch<any[]>(endpoint);
      if (!response || response.length < 2 || !response[1].data.children?.[0]) {
        throw new RedditError(`Comment thread not found`, "API_ERROR");
      }
      // The first comment in the thread is our target comment
      const commentData = response[1].data.children[0];
      if (commentData.kind !== "t1") {
        throw new RedditError(`Invalid comment data received`, "API_ERROR");
      }
      const comment = transformComment(commentData.data);
      // Process replies if they exist
      let replies: RedditCommentThread[] = [];
      if (
        commentData.data.replies &&
        typeof commentData.data.replies === "object" &&
        commentData.data.replies.data?.children
      ) {
        replies = this.processCommentTree(commentData.data.replies.data.children);
      }
      return {
        comment,
        replies,
      };
    } catch (error) {
      throw new RedditError(
        `Failed to fetch comment thread: ${error instanceof Error ? error.message : "Unknown error"}`,
        "API_ERROR",
        error,
      );
    }
  }
  public async searchReddit(options: {
    query: string;
    subreddit?: string;
    sort?: "relevance" | "hot" | "new" | "top";
    time?: "hour" | "day" | "week" | "month" | "year" | "all";
    limit?: number;
  }): Promise<RedditPost[]> {
    const { query, subreddit, sort = "relevance", time = "all", limit = DEFAULT_POST_LIMIT } = options;
    const formattedSubreddit = this.formatSubreddit(subreddit);
    let endpoint = formattedSubreddit ? `/r/${formattedSubreddit}/search.json` : "/search.json";
    const params = new URLSearchParams({
      q: query,
      sort,
      t: time,
      limit: limit.toString(),
      restrict_sr: formattedSubreddit ? "true" : "false",
    });
    endpoint += `?${params.toString()}`;
    const data = await this.redditFetch<RedditApiResponse<RedditPost>>(endpoint);
    return data.data.children?.map((child) => transformPost(child.data)) ?? [];
  }
  /**
   * Sends a reply to a post or comment
   * @param id - The ID of the parent post or comment to reply to
   * @param text - The content of the reply
   * @returns Promise<any> - The API response
   */
  public async sendReply(id: string, text: string): Promise<any> {
    try {
      const formData = new URLSearchParams({
        parent: id,
        text: text,
      });
      const response = await this.redditFetch("/api/comment", {
        method: "POST",
        body: formData,
      });
      return response;
    } catch (error) {
      throw new RedditError(
        `Failed to send reply: ${error instanceof Error ? error.message : "Unknown error"}`,
        "API_ERROR",
        error,
      );
    }
  }
  public async sendComment(id: string, text: string): Promise<any> {
    try {
      const response = await this.redditFetch<any>("/api/comment", {
        method: "POST",
        headers: {
          "Content-Type": "application/x-www-form-urlencoded",
        },
        body: new URLSearchParams({
          parent_id: id,
          text,
        }).toString(),
      });
      return {
        id: response.id,
        text: response.text,
        permalink: response.permalink,
      };
    } catch (error) {
      throw error;
    }
  }
  /**
   * Sends a private message to another Reddit user
   * 
   * @param params - Message parameters
   * @param params.recipient - Username of recipient (with or without u/ prefix)
   * @param params.subject - Message subject (max 100 characters)
   * @param params.content - Message body (max 10000 characters)
   * @returns Response containing sent message details
   * @throws {RedditError} Thrown if:
   *   - Recipient is missing or invalid
   *   - Reddit API returns errors
   *   - API request fails
   * 
   * @remarks
   * The method automatically:
   * - Cleans the recipient username (removes u/ prefix)
   * - Truncates subject to 100 characters
   * - Generates a unique message ID
   * - Handles Reddit API error responses
   * 
   * @example
   * ```typescript
   * const message = await postService.sendMessage({
   *   recipient: 'username',
   *   subject: 'Hello!',
   *   content: 'This is a test message.'
   * });
   * console.log(`Message sent with ID: ${message.id}`);
   * ```
   */
  public async sendMessage(params: RedditMessageParams): Promise<RedditMessageResponse> {
    try {
      const { recipient, subject, content } = params;
      if (!recipient) {
        throw new RedditError("Recipient is required", "VALIDATION_ERROR");
      }
      // Remove any prefix (u/, /u/, etc) and trim whitespace
      const cleanRecipient = recipient.replace(/^(?:u\/|\/u\/)/i, "").trim();
      if (!cleanRecipient) {
        throw new RedditError("Invalid recipient username", "VALIDATION_ERROR");
      }
      // Create form data with proper encoding
      const formData = new URLSearchParams();
      formData.append("api_type", "json");
      formData.append("subject", subject.slice(0, 100));
      formData.append("text", content);
      formData.append("to", cleanRecipient);
      const response = await this.redditFetch<{
        json: {
          errors: Array<[string, string, string]>; // Reddit returns errors as [name, message, field]
          data?: {
            things: Array<{
              data: {
                id: string;
                name: string;
              };
            }>;
          };
        };
      }>("/api/compose", {
        method: "POST",
        headers: {
          "Content-Type": "application/x-www-form-urlencoded",
        },
        body: formData.toString(),
      });
      // Check for errors in the response
      if (response.json?.errors && response.json.errors.length > 0) {
        const errorMessages = response.json.errors.map(([, message]) => message).join(", ");
        throw new RedditError(`Reddit API Error: ${errorMessages}`, "API_ERROR");
      }
      // Generate a unique ID for the message since Reddit doesn't return one in a consistent format
      const messageId = `t4_${Date.now().toString(36)}`;
      return {
        id: messageId,
        recipient: cleanRecipient,
        subject,
        body: content,
      };
    } catch (error) {
      if (error instanceof RedditError) {
        throw error;
      }
      throw new RedditError(
        `Failed to send message: ${error instanceof Error ? error.message : "Unknown error"}`,
        "API_ERROR",
        error,
      );
    }
  }
}