UNPKG

systemprompt-mcp-reddit

Version:

A specialized Model Context Protocol (MCP) server that enables you to search, read, and interact with Reddit content, leveraging an AI Agent to help with each operation.

307 lines 12.1 kB
import { RedditError, } from "../../types/reddit.js"; import { RedditAuthService } from "./reddit-auth-service.js"; import { RedditPostService } from "./reddit-post-service.js"; import { RedditSubredditService } from "./reddit-subreddit-service.js"; import { transformToConfigNotification } from "../../utils/reddit-transformers.js"; /** * Main service for interacting with the Reddit API * Implements facade pattern to coordinate between specialized services */ export class RedditService { constructor() { this.baseUrl = "https://oauth.reddit.com"; this.rateLimitDelay = 2000; // 1 request per second this.initialized = false; /** * Formats subreddit info for the config response */ this.formatSubredditInfo = (subreddit) => { return { id: subreddit.id, name: subreddit.name, display_name: subreddit.displayName, title: subreddit.title, description: subreddit.description, subscribers: subreddit.subscribers, created_utc: subreddit.createdUtc, url: subreddit.url, over18: subreddit.isNsfw, icon_img: subreddit.icon, user_is_subscriber: true, // Since this is from subscribed subreddits user_is_moderator: false, // We don't have this info from the subscribed endpoint rules: [], // Rules are not included in the subscribed endpoint post_requirements: undefined, // Post requirements are not included in the subscribed endpoint }; }; this.initialized = false; const config = this.loadConfig(); this.authService = new RedditAuthService(config); this.postService = new RedditPostService(this.baseUrl, this.authService, this.rateLimitDelay); this.subredditService = new RedditSubredditService(this.baseUrl, this.authService, this.rateLimitDelay); } static getInstance() { if (!RedditService.instance) { RedditService.instance = new RedditService(); } return RedditService.instance; } /** * Loads configuration from environment variables * @throws {RedditError} if required environment variables are missing */ loadConfig() { const requiredEnvVars = { clientId: process.env.REDDIT_CLIENT_ID ?? "", clientSecret: process.env.REDDIT_CLIENT_SECRET ?? "", refreshToken: process.env.REDDIT_REFRESH_TOKEN ?? "", }; const missingVars = Object.entries(requiredEnvVars) .filter(([, value]) => !value) .map(([key]) => key); if (missingVars.length > 0) { throw new RedditError(`Missing required environment variables: ${missingVars.join(", ")}`, "CONFIGURATION_ERROR"); } return { ...requiredEnvVars, appName: "Systemprompt MCP Reddit", appVersion: "1.0.9", username: "AutomatedBot", }; } /** * Initialize the service and authenticate * @throws {RedditError} if initialization fails */ async initialize() { if (this.initialized) return; try { await this.authService.initialize(); this.initialized = true; } catch (error) { throw new RedditError(`Failed to initialize Reddit service: ${error instanceof Error ? error.message : "Unknown error"}`, "INITIALIZATION_ERROR", error); } } /** * Fetches posts based on provided options */ async fetchPosts(options) { this.checkInitialized(); return this.postService.fetchPosts(options); } /** * Creates a new post on Reddit * @throws {RedditError} if post creation fails or validation fails */ async createPost(params) { this.checkInitialized(); // Validate title length if (params.title.length < 1 || params.title.length > 300) { throw new RedditError("Post title must be between 1 and 300 characters", "VALIDATION_ERROR"); } // Get subreddit requirements const subredditInfo = await this.getSubredditInfo(params.subreddit); // Check if flair is required if (subredditInfo.flairRequired && !params.flair_id && !params.flair_text) { throw new RedditError(`Flair is required for posts in r/${params.subreddit}`, "VALIDATION_ERROR"); } return this.postService.createPost(params); } /** * Fetches subreddit information and rules */ async getSubredditInfo(subreddit) { this.checkInitialized(); return this.subredditService.getSubredditInfo(subreddit); } /** * Fetches a single Reddit post by its ID * @param id The ID of the post to fetch * @returns The fetched post with comments */ async fetchPostById(id) { this.checkInitialized(); return this.postService.fetchPostById(id); } /** * Fetches user notifications (inbox items) from Reddit * @param options Options for fetching notifications * @returns Array of notifications */ async fetchNotifications(options = {}) { this.checkInitialized(); const response = await this.postService.fetchNotifications(options); return response; } /** * Fetches the list of subreddits the authenticated user is subscribed to * @param options Options for fetching subscribed subreddits * @returns Array of subscribed subreddits */ async fetchSubscribedSubreddits(options = {}) { this.checkInitialized(); return this.subredditService.fetchSubscribedSubreddits(options); } async fetchUserInfo() { this.checkInitialized(); return this.authService.fetchUserInfo(); } async fetchUserPreferences() { this.checkInitialized(); return this.authService.fetchUserPreferences(); } /** * Formats a notification for the config response */ formatNotification(notification) { return transformToConfigNotification(notification); } /** * Gets the complete Reddit configuration including user info, notifications, * and subscribed subreddits */ async getRedditConfig() { this.checkInitialized(); try { // Fetch all required data in parallel const [allNotifications, subscribedSubreddits, userInfo, userPreferences] = await Promise.all([ this.fetchNotifications({ filter: "all", limit: 10, markRead: false }), this.fetchSubscribedSubreddits({ limit: 50 }), this.fetchUserInfo(), this.fetchUserPreferences(), ]); // Transform notifications and messages const transformed = allNotifications.map((n) => this.formatNotification(n)); // Separate messages from notifications const messages = transformed.filter((n) => n.type === "message"); const notifications = transformed.filter((n) => n.type !== "message"); // Format the data according to our schema return { notifications, messages, subscribedSubreddits: subscribedSubreddits.map(this.formatSubredditInfo), user: { ...userInfo, preferences: userPreferences, }, }; } catch (error) { throw new RedditError(`Failed to fetch Reddit configuration: ${error instanceof Error ? error.message : error}`, "API_ERROR"); } } /** * Search Reddit posts */ async searchReddit(options) { this.checkInitialized(); return this.postService.searchReddit(options); } /** * Fetches a single comment by its ID * @param id The ID of the comment to fetch * @returns The fetched comment */ async fetchCommentById(id) { this.checkInitialized(); return this.postService.fetchCommentById(id); } /** * Fetches a comment thread (comment with all its replies) * @param id The ID of the post containing the comment * @param id The ID of the comment to fetch * @returns The comment thread with all replies */ async fetchCommentThread(parentId, id) { this.checkInitialized(); return this.postService.fetchCommentThread(parentId, id); } /** * Sends a reply to a post or comment * @param params Reply parameters including parent ID and text * @throws {RedditError} if reply fails or validation fails */ async sendReply(params) { this.checkInitialized(); // Validate parent ID format if (!params.id.match(/^t[1|3]_[a-z0-9]+$/i)) { throw new RedditError("Invalid parent ID format. Must start with t1_ or t3_", "VALIDATION_ERROR"); } // Validate text length if (params.text.length > 10000) { throw new RedditError("Reply text exceeds maximum length of 10000 characters", "VALIDATION_ERROR"); } return this.postService.sendReply(params.id, params.text); } /** * Fetches available post flairs for a subreddit * @param subreddit The subreddit name (without r/ prefix) * @returns Array of available flairs */ async getSubredditFlairs(subreddit) { try { const response = await this.subredditService.getFlairs(subreddit); return response.choices.map((flair) => ({ id: flair.flair_template_id, text: flair.text, type: flair.type, textEditable: flair.text_editable, backgroundColor: flair.background_color, textColor: flair.text_color, modOnly: flair.mod_only, })); } catch (error) { // If we can't fetch flairs (e.g., no permission, subreddit doesn't exist), return empty array console.warn(`Failed to fetch flairs for subreddit ${subreddit}:`, error); return []; } } async sendComment(params) { try { const { id, text, sendreplies = true } = params; if (!id) { throw new RedditError("id is required for sending comments", "VALIDATION_ERROR"); } if (!text) { throw new RedditError("text is required for sending comments", "VALIDATION_ERROR"); } // Validate ID format if (!/^t[1|3]_[a-z0-9]+$/.test(id)) { throw new RedditError("Invalid ID format. Must start with t1_ for comments or t3_ for posts", "VALIDATION_ERROR"); } return this.postService.sendComment(params.id, params.text); } catch (error) { throw error; } } async sendMessage(params) { this.checkInitialized(); try { const { recipient, subject, content } = params; if (!recipient || !subject || !content) { throw new RedditError("Missing required fields", "VALIDATION_ERROR"); } // Validate subject length if (subject.length > 100) { throw new RedditError("Subject exceeds maximum length of 100 characters", "VALIDATION_ERROR"); } // Validate content length if (content.length > 10000) { throw new RedditError("Content exceeds maximum length of 10000 characters", "VALIDATION_ERROR"); } return this.postService.sendMessage(params); } catch (error) { throw error; } } checkInitialized() { if (!this.initialized) { throw new RedditError("RedditService not initialized. Call initialize() first", "INITIALIZATION_ERROR"); } } } //# sourceMappingURL=reddit-service.js.map