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
JavaScript
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