diff --git a/packages/client-reddit/.npmignore b/packages/client-reddit/.npmignore new file mode 100644 index 00000000000..078562eceab --- /dev/null +++ b/packages/client-reddit/.npmignore @@ -0,0 +1,6 @@ +* + +!dist/** +!package.json +!readme.md +!tsup.config.ts \ No newline at end of file diff --git a/packages/client-reddit/eslint.config.mjs b/packages/client-reddit/eslint.config.mjs new file mode 100644 index 00000000000..92fe5bbebef --- /dev/null +++ b/packages/client-reddit/eslint.config.mjs @@ -0,0 +1,3 @@ +import eslintGlobalConfig from "../../eslint.config.mjs"; + +export default [...eslintGlobalConfig]; diff --git a/packages/client-reddit/package.json b/packages/client-reddit/package.json new file mode 100644 index 00000000000..710c139a742 --- /dev/null +++ b/packages/client-reddit/package.json @@ -0,0 +1,22 @@ +{ + "name": "@ai16z/client-reddit", + "version": "0.1.0", + "main": "dist/index.js", + "type": "module", + "types": "dist/index.d.ts", + "dependencies": { + "@ai16z/eliza": "workspace:*", + "snoowrap": "^1.23.0" + }, + "devDependencies": { + "@types/node": "^20.0.0", + "tsup": "8.3.5", + "vitest": "^2.1.4" + }, + "scripts": { + "build": "tsup --format esm --dts", + "dev": "tsup --format esm --dts --watch", + "test": "vitest run", + "test:watch": "vitest watch" + } +} diff --git a/packages/client-reddit/src/actions/comment.ts b/packages/client-reddit/src/actions/comment.ts new file mode 100644 index 00000000000..fe11bc27625 --- /dev/null +++ b/packages/client-reddit/src/actions/comment.ts @@ -0,0 +1,49 @@ +import { Action, IAgentRuntime, Memory } from "@ai16z/eliza"; + +export const createComment: Action = { + name: "CREATE_REDDIT_COMMENT", + similes: ["COMMENT_ON_REDDIT", "REPLY_ON_REDDIT"], + validate: async (runtime: IAgentRuntime, message: Memory) => { + const hasCredentials = !!runtime.getSetting("REDDIT_CLIENT_ID") && + !!runtime.getSetting("REDDIT_CLIENT_SECRET") && + !!runtime.getSetting("REDDIT_REFRESH_TOKEN"); + return hasCredentials; + }, + handler: async ( + runtime: IAgentRuntime, + message: Memory, + state: any, + options: any + ) => { + const { reddit } = await runtime.getProvider("redditProvider"); + + // Extract post ID and comment content from message + const postId = options.postId; // This should be a fullname (t3_postid) + const content = message.content.text; + + try { + await reddit.getSubmission(postId).reply(content); + return true; + } catch (error) { + console.error("Failed to create Reddit comment:", error); + return false; + } + }, + examples: [ + [ + { + user: "{{user1}}", + content: { + text: "Comment on this Reddit post: t3_abc123 with: Great post!" + }, + }, + { + user: "{{agentName}}", + content: { + text: "I'll add that comment to the Reddit post", + action: "CREATE_REDDIT_COMMENT", + }, + }, + ], + ], +}; diff --git a/packages/client-reddit/src/actions/post.ts b/packages/client-reddit/src/actions/post.ts new file mode 100644 index 00000000000..bd3d53c597a --- /dev/null +++ b/packages/client-reddit/src/actions/post.ts @@ -0,0 +1,88 @@ +import { Action, IAgentRuntime, Memory } from "@ai16z/eliza"; +import { RedditPost } from "../types"; + +export const createPost: Action = { + name: "CREATE_REDDIT_POST", + similes: ["POST_TO_REDDIT", "SUBMIT_REDDIT_POST"], + validate: async (runtime: IAgentRuntime, message: Memory) => { + const hasCredentials = !!runtime.getSetting("REDDIT_CLIENT_ID") && + !!runtime.getSetting("REDDIT_CLIENT_SECRET") && + !!runtime.getSetting("REDDIT_REFRESH_TOKEN"); + return hasCredentials; + }, + handler: async ( + runtime: IAgentRuntime, + message: Memory, + state: any, + options: any + ) => { + const { reddit } = await runtime.getProvider("redditProvider"); + + // Parse the subreddit and content from the message + // Expected format: "Post to r/subreddit: Title | Content" + const messageText = message.content.text; + const match = messageText.match(/Post to r\/(\w+):\s*([^|]+)\|(.*)/i); + + if (!match) { + throw new Error("Invalid post format. Use: Post to r/subreddit: Title | Content"); + } + + const [_, subreddit, title, content] = match; + + try { + const post = await reddit.submitSelfpost({ + subredditName: subreddit.trim(), + title: title.trim(), + text: content.trim() + }); + + return { + success: true, + data: { + id: post.id, + url: post.url, + subreddit: post.subreddit.display_name, + title: post.title + } + }; + } catch (error) { + console.error("Failed to create Reddit post:", error); + return { + success: false, + error: error.message + }; + } + }, + examples: [ + [ + { + user: "{{user1}}", + content: { + text: "Post to r/test: My First Post | This is the content of my post" + }, + }, + { + user: "{{agentName}}", + content: { + text: "I'll create that post on r/test for you", + action: "CREATE_REDDIT_POST", + }, + }, + ], + [ + { + user: "{{user1}}", + content: { + text: "Post to r/AskReddit: What's your favorite book? | I'm curious to know what books everyone loves and why." + }, + }, + { + user: "{{agentName}}", + content: { + text: "Creating your post on r/AskReddit", + action: "CREATE_REDDIT_POST", + }, + }, + ], + ], +}; diff --git a/packages/client-reddit/src/actions/vote.ts b/packages/client-reddit/src/actions/vote.ts new file mode 100644 index 00000000000..ff4dbe040d7 --- /dev/null +++ b/packages/client-reddit/src/actions/vote.ts @@ -0,0 +1,49 @@ +import { Action, IAgentRuntime, Memory } from "@ai16z/eliza"; + +export const vote: Action = { + name: "REDDIT_VOTE", + similes: ["UPVOTE_ON_REDDIT", "DOWNVOTE_ON_REDDIT"], + validate: async (runtime: IAgentRuntime, message: Memory) => { + const hasCredentials = !!runtime.getSetting("REDDIT_CLIENT_ID") && + !!runtime.getSetting("REDDIT_CLIENT_SECRET") && + !!runtime.getSetting("REDDIT_REFRESH_TOKEN"); + return hasCredentials; + }, + handler: async ( + runtime: IAgentRuntime, + message: Memory, + state: any, + options: any + ) => { + const { reddit } = await runtime.getProvider("redditProvider"); + + // Extract target ID and vote direction + const targetId = options.targetId; // fullname of post/comment + const direction = options.direction; // 1 for upvote, -1 for downvote + + try { + await reddit.getSubmission(targetId).upvote(); // or downvote() + return true; + } catch (error) { + console.error("Failed to vote on Reddit:", error); + return false; + } + }, + examples: [ + [ + { + user: "{{user1}}", + content: { + text: "Upvote this Reddit post: t3_abc123" + }, + }, + { + user: "{{agentName}}", + content: { + text: "I'll upvote that post for you", + action: "REDDIT_VOTE", + }, + }, + ], + ], +}; diff --git a/packages/client-reddit/src/clients/redditClient.ts b/packages/client-reddit/src/clients/redditClient.ts new file mode 100644 index 00000000000..153b05507a4 --- /dev/null +++ b/packages/client-reddit/src/clients/redditClient.ts @@ -0,0 +1,52 @@ +import { IAgentRuntime, elizaLogger } from "@ai16z/eliza"; +import { RedditProvider } from "../providers/redditProvider"; +import { RedditPostClient } from "./redditPostClient"; +import Snoowrap from "snoowrap"; + +export class RedditClient { + provider: RedditProvider; + postClient: RedditPostClient; + runtime: IAgentRuntime; + + constructor(runtime: IAgentRuntime) { + this.runtime = runtime; + const reddit = new Snoowrap({ + userAgent: runtime.getSetting("REDDIT_USER_AGENT"), + clientId: runtime.getSetting("REDDIT_CLIENT_ID"), + clientSecret: runtime.getSetting("REDDIT_CLIENT_SECRET"), + refreshToken: runtime.getSetting("REDDIT_REFRESH_TOKEN") + }); + this.provider = new RedditProvider(runtime, reddit); + this.postClient = new RedditPostClient(runtime, this.provider); + } + + async start() { + elizaLogger.info("Starting Reddit client"); + await this.provider.start(); + + const autoPost = this.runtime.getSetting("REDDIT_AUTO_POST") === "true"; + if (autoPost) { + elizaLogger.info("Auto-posting enabled for Reddit"); + await this.postClient.start(true); + } + } + + async stop() { + elizaLogger.info("Stopping Reddit client"); + await this.postClient.stop(); + } + + async submitPost(subreddit: string, title: string, content: string) { + try { + const post = await this.provider.submitSelfpost({ + subredditName: subreddit, + title, + text: content + }); + return post; + } catch (error) { + elizaLogger.error("Error submitting Reddit post:", error); + throw error; + } + } +} \ No newline at end of file diff --git a/packages/client-reddit/src/clients/redditPostClient.ts b/packages/client-reddit/src/clients/redditPostClient.ts new file mode 100644 index 00000000000..eb6b9bf752d --- /dev/null +++ b/packages/client-reddit/src/clients/redditPostClient.ts @@ -0,0 +1,259 @@ +import { IAgentRuntime, elizaLogger, generateText, ModelClass, composeContext } from "@ai16z/eliza"; +import { RedditProvider } from "../providers/redditProvider"; + +const redditPostTemplate = ` +# Areas of Expertise +{{knowledge}} + +# About {{agentName}}: +{{bio}} +{{lore}} +{{topics}} + +{{providers}} + +{{characterPostExamples}} + +{{postDirections}} + +# Task: Generate a Reddit post in the voice and style of {{agentName}}. +Write a post for r/{{subreddit}} that is {{adjective}} about {{topic}}. +Title should be brief, engaging, and use only basic alphanumeric characters. +Content should be 2-4 sentences, natural and conversational. No markdown, emojis, or special characters. + +Format your response as: +Title: +Content: `; + +export class RedditPostClient { + runtime: IAgentRuntime; + reddit: RedditProvider; + private stopProcessing: boolean = false; + + constructor(runtime: IAgentRuntime, reddit: RedditProvider) { + this.runtime = runtime; + this.reddit = reddit; + } + + async start(postImmediately: boolean = false) { + if (postImmediately) { + await this.generateNewPost(); + } + + this.startPostingLoop(); + } + + private async startPostingLoop() { + while (!this.stopProcessing) { + try { + const lastPost = await this.runtime.cacheManager.get<{ + timestamp: number; + }>("reddit/lastPost"); + + const lastPostTimestamp = lastPost?.timestamp ?? 0; + const minMinutes = parseInt(this.runtime.getSetting("POST_INTERVAL_MIN")) || 90; + const maxMinutes = parseInt(this.runtime.getSetting("POST_INTERVAL_MAX")) || 180; + const randomMinutes = Math.floor(Math.random() * (maxMinutes - minMinutes + 1)) + minMinutes; + const delay = randomMinutes * 60 * 1000; + + if (Date.now() > lastPostTimestamp + delay) { + await this.generateNewPost(); + } + + elizaLogger.log(`Next Reddit post scheduled in ${randomMinutes} minutes`); + await new Promise(resolve => setTimeout(resolve, delay)); + } catch (error) { + elizaLogger.error("Error in Reddit posting loop:", error); + await new Promise(resolve => setTimeout(resolve, 5 * 60 * 1000)); // Wait 5 minutes on error + } + } + } + + private async submitWithRateLimit(subreddit: string, title: string, content: string) { + elizaLogger.debug("Applying rate limit before submission"); + await new Promise(resolve => setTimeout(resolve, 2000)); + + try { + // Validate content lengths + if (title.length === 0 || title.length > 300) { + throw new Error(`Title length must be between 1-300 characters (current: ${title.length})`); + } + if (content.length > 40000) { + throw new Error(`Content exceeds maximum length of 40,000 characters (current: ${content.length})`); + } + + // Clean up the content + const cleanTitle = title + .replace(/[^\w\s-]/g, '') // Remove all non-word chars except spaces and hyphens + .replace(/\s+/g, ' ') // Replace multiple spaces with single space + .trim(); + + const cleanContent = content + .replace(/\*[^*]*\*/g, '') // Remove markdown asterisks and content between them + .replace(/[^\w\s.,!?-]/g, '') // Keep only basic punctuation + .replace(/\s+/g, ' ') // Replace multiple spaces with single space + .trim(); + + // Validate after cleaning + if (!cleanTitle || !cleanContent) { + throw new Error("Title or content is empty after cleaning"); + } + + // Log submission attempt + elizaLogger.info("Attempting Reddit submission:", { + subreddit, + cleanTitle, + titleLength: cleanTitle.length, + contentLength: cleanContent.length, + userAgent: this.reddit.reddit.userAgent, + hasAuth: !!this.reddit.reddit.accessToken + }); + + const post = await this.reddit.submitSelfpost({ + subredditName: subreddit, + title: cleanTitle, + text: cleanContent + }); + + elizaLogger.info("Post submitted successfully:", { + url: `https://reddit.com${post.permalink}`, + subreddit: post.subreddit_name_prefixed, + title: post.title, + upvotes: post.score + }); + + return post; + } catch (error) { + const errorDetails = error.response?.body || error.message; + elizaLogger.error("Reddit API submission error:", { + error: { + name: error.name, + message: error.message, + details: errorDetails, + status: error.statusCode, + stack: error.stack + }, + submission: { + subreddit, + titleLength: title.length, + contentLength: content.length, + hasAuth: !!this.reddit.reddit.accessToken, + userAgent: this.reddit.reddit.userAgent + } + }); + throw error; + } + } + + private async generateNewPost() { + elizaLogger.info("=== Starting Reddit Post Generation ==="); + + try { + // Log settings check + elizaLogger.debug("Checking Reddit credentials:", { + hasClientId: !!this.runtime.getSetting("REDDIT_CLIENT_ID"), + hasClientSecret: !!this.runtime.getSetting("REDDIT_CLIENT_SECRET"), + hasRefreshToken: !!this.runtime.getSetting("REDDIT_REFRESH_TOKEN") + }); + + // Subreddit selection + const subreddits = (this.runtime.getSetting("REDDIT_SUBREDDITS") || "test").split(","); + const subreddit = subreddits[Math.floor(Math.random() * subreddits.length)].trim(); + elizaLogger.info(`Selected subreddit: r/${subreddit}`); + + // State composition + elizaLogger.debug("Composing state for post generation"); + const state = await this.runtime.composeState( + { + userId: this.runtime.agentId, + roomId: `reddit-${this.runtime.agentId}`, + content: { text: "", action: "POST" } + }, + { subreddit } + ); + elizaLogger.debug("State composed successfully", { state }); + + // Context creation + elizaLogger.debug("Creating context with template"); + const context = composeContext({ + state, + template: redditPostTemplate + }); + elizaLogger.debug("Context created successfully"); + + // Text generation + elizaLogger.info("Generating post content..."); + const response = await generateText({ + runtime: this.runtime, + context, + modelClass: ModelClass.MEDIUM + }); + elizaLogger.debug("Raw generated response:", response); + + // Response parsing + const titleMatch = response.match(/Title:\s*(.*)/i); + const contentMatch = response.match(/Content:\s*(.*)/is); + + if (!titleMatch || !contentMatch) { + elizaLogger.error("Failed to parse post content:", { + response, + hasTitleMatch: !!titleMatch, + hasContentMatch: !!contentMatch + }); + return; + } + + const title = titleMatch[1].trim(); + const content = contentMatch[1].trim(); + elizaLogger.info("Parsed post content:", { + title, + content, + titleLength: title.length, + contentLength: content.length + }); + + // Dry run check + if (this.runtime.getSetting("REDDIT_DRY_RUN") === "true") { + elizaLogger.info(`[DRY RUN] Would post to r/${subreddit}:`, { + title, + content + }); + return; + } + + // Post submission + elizaLogger.info(`Attempting to submit post to r/${subreddit}`); + try { + const post = await this.submitWithRateLimit(subreddit, title, content); + + // Cache update + elizaLogger.debug("Updating last post cache"); + await this.runtime.cacheManager.set("reddit/lastPost", { + id: post.id, + timestamp: Date.now() + }); + + elizaLogger.success(`Successfully posted to Reddit: ${post.url}`); + } catch (error) { + // ... existing error handling ... + } + + } catch (error) { + elizaLogger.error("Error in Reddit post generation:", { + error: error instanceof Error ? { + name: error.name, + message: error.message, + stack: error.stack + } : error, + runtime: { + agentId: this.runtime.agentId, + hasRedditProvider: !!this.reddit + } + }); + } + } + + async stop() { + this.stopProcessing = true; + } +} \ No newline at end of file diff --git a/packages/client-reddit/src/index.ts b/packages/client-reddit/src/index.ts new file mode 100644 index 00000000000..1a73d52dc2b --- /dev/null +++ b/packages/client-reddit/src/index.ts @@ -0,0 +1,28 @@ +import { Plugin, Client } from "@ai16z/eliza"; +import { createPost } from "./actions/post"; +import { createComment } from "./actions/comment"; +import { vote } from "./actions/vote"; +import { redditProvider } from "./providers/redditProvider"; +import { RedditClient } from "./clients/redditClient"; + +export const RedditClientInterface: Client = { + async start(runtime) { + const client = new RedditClient(runtime); + await client.start(); + return client; + }, + async stop(runtime) { + // Cleanup logic + } +}; + +export const redditPlugin: Plugin = { + name: "reddit", + description: "Reddit Plugin for Eliza - Interact with Reddit posts, comments and voting", + actions: [createPost, createComment, vote], + providers: [redditProvider], + evaluators: [], + clients: [RedditClientInterface] +}; + +export default redditPlugin; diff --git a/packages/client-reddit/src/providers/redditProvider.ts b/packages/client-reddit/src/providers/redditProvider.ts new file mode 100644 index 00000000000..cdc3d3b8f68 --- /dev/null +++ b/packages/client-reddit/src/providers/redditProvider.ts @@ -0,0 +1,123 @@ +import { Provider, IAgentRuntime, elizaLogger } from "@ai16z/eliza"; +import Snoowrap from "snoowrap"; +import { RedditPostClient } from "../clients/redditPostClient"; + +export class RedditProvider { + postClient: RedditPostClient; + runtime: IAgentRuntime; + reddit: Snoowrap; + + constructor(runtime: IAgentRuntime, reddit?: Snoowrap) { + this.runtime = runtime; + if (reddit) { + this.reddit = reddit; + } else { + // Use the provided user agent or build one + const userAgent = this.runtime.getSetting("REDDIT_USER_AGENT"); + + this.reddit = new Snoowrap({ + userAgent, + clientId: runtime.getSetting("REDDIT_CLIENT_ID"), + clientSecret: runtime.getSetting("REDDIT_CLIENT_SECRET"), + refreshToken: runtime.getSetting("REDDIT_REFRESH_TOKEN"), + accessToken: this.runtime.getSetting("REDDIT_ACCESS_TOKEN"), + // Add these options for better auth handling + endpointDomain: 'oauth.reddit.com', + requestDelay: 1000, + continueAfterRatelimitError: true, + retryErrorCodes: [502, 503, 504, 522], + maxRetryAttempts: 3 + }); + + // Configure additional options + this.reddit.config({ + debug: true, + proxies: false, + requestTimeout: 30000 + }); + } + this.postClient = new RedditPostClient(runtime, this); + } + + async start() { + try { + elizaLogger.info("Starting Reddit authentication with:", { + userAgent: this.reddit.userAgent, + clientIdExists: !!this.runtime.getSetting("REDDIT_CLIENT_ID"), + secretExists: !!this.runtime.getSetting("REDDIT_CLIENT_SECRET"), + tokenExists: !!this.runtime.getSetting("REDDIT_REFRESH_TOKEN"), + accessTokenExists: !!this.runtime.getSetting("REDDIT_ACCESS_TOKEN") + }); + + // Test authentication + const me = await this.reddit.getMe(); + elizaLogger.info("Reddit authentication successful", { + username: me.name, + karma: me.link_karma + me.comment_karma + }); + + // Start the post client after successful auth + const postImmediately = this.runtime.getSetting("POST_IMMEDIATELY") === "true"; + await this.postClient.start(postImmediately); + + } catch (error) { + elizaLogger.error("Reddit authentication failed", { + error: error.message, + response: error.response?.body, + statusCode: error.statusCode, + headers: error.response?.headers + }); + throw error; + } + } + + async submitSelfpost({ subredditName, title, text }: { + subredditName: string; + title: string; + text: string; + }) { + try { + elizaLogger.info(`Attempting to submit post to r/${subredditName}:`, { + title, + contentLength: text.length + }); + + const subreddit = await this.reddit.getSubreddit(subredditName); + const post = await subreddit.submitSelfpost({ title, text }); + + elizaLogger.success(`Successfully posted to Reddit:`, { + url: `https://reddit.com${post.permalink}`, + subreddit: subredditName, + title: post.title, + upvotes: post.score + }); + + return post; + } catch (error) { + elizaLogger.error("Failed to submit post", { + error: error.message, + subreddit: subredditName, + isAuthenticated: !!this.reddit.accessToken + }); + throw error; + } + } +} + +export const redditProvider: Provider = { + provide: async (runtime: IAgentRuntime) => { + const userAgent = runtime.getSetting("REDDIT_USER_AGENT"); + + const reddit = new Snoowrap({ + userAgent, + clientId: runtime.getSetting("REDDIT_CLIENT_ID"), + clientSecret: runtime.getSetting("REDDIT_CLIENT_SECRET"), + refreshToken: runtime.getSetting("REDDIT_REFRESH_TOKEN"), + endpointDomain: 'oauth.reddit.com' + }); + + const provider = new RedditProvider(runtime, reddit); + await provider.start(); + return { reddit: provider }; + } +}; diff --git a/packages/client-reddit/src/types/index.ts b/packages/client-reddit/src/types/index.ts new file mode 100644 index 00000000000..bcca1ad8a59 --- /dev/null +++ b/packages/client-reddit/src/types/index.ts @@ -0,0 +1,18 @@ +export interface RedditPost { + id: string; + subreddit: string; + title: string; + content: string; + author: string; + score: number; + created: Date; +} + +export interface RedditComment { + id: string; + postId: string; + content: string; + author: string; + score: number; + created: Date; +} diff --git a/packages/client-reddit/tsconfig.json b/packages/client-reddit/tsconfig.json new file mode 100644 index 00000000000..834c4dce269 --- /dev/null +++ b/packages/client-reddit/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "../core/tsconfig.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src", + "types": [ + "node" + ] + }, + "include": [ + "src/**/*.ts" + ] +} \ No newline at end of file diff --git a/packages/client-reddit/tsup.config.ts b/packages/client-reddit/tsup.config.ts new file mode 100644 index 00000000000..b5e4388b214 --- /dev/null +++ b/packages/client-reddit/tsup.config.ts @@ -0,0 +1,21 @@ +import { defineConfig } from "tsup"; + +export default defineConfig({ + entry: ["src/index.ts"], + outDir: "dist", + sourcemap: true, + clean: true, + format: ["esm"], // Ensure you're targeting CommonJS + external: [ + "dotenv", // Externalize dotenv to prevent bundling + "fs", // Externalize fs to use Node.js built-in module + "path", // Externalize other built-ins if necessary + "@reflink/reflink", + "@node-llama-cpp", + "https", + "http", + "agentkeepalive", + "zod", + // Add other modules you want to externalize + ], +});