From 5bbe5fdbd75a02631d516fec46c48efb1145e2e9 Mon Sep 17 00:00:00 2001 From: Serkan Aydin Date: Sun, 28 Dec 2025 21:05:52 +0100 Subject: [PATCH] feat(farcaster): Add get_user_details, reply_to_cast, get_feed, and get_mentions actions This PR adds 4 new actions to the Farcaster Action Provider: 1. get_user_details - Look up any user by username or FID 2. reply_to_cast - Reply to existing casts with optional embeds 3. get_feed - Get a user's casts/feed with configurable options 4. get_mentions - Get casts that mention the agent These additions address items from WISHLIST.md: - Get other account details - Handle replies - Get feed context (previous casts) All actions include comprehensive tests and updated documentation. --- .../src/action-providers/farcaster/README.md | 37 ++- .../farcaster/farcasterActionProvider.test.ts | 255 +++++++++++++++++- .../farcaster/farcasterActionProvider.ts | 201 +++++++++++++- .../src/action-providers/farcaster/schemas.ts | 54 ++++ 4 files changed, 543 insertions(+), 4 deletions(-) diff --git a/typescript/agentkit/src/action-providers/farcaster/README.md b/typescript/agentkit/src/action-providers/farcaster/README.md index e83a29db8..1a18fa1a4 100644 --- a/typescript/agentkit/src/action-providers/farcaster/README.md +++ b/typescript/agentkit/src/action-providers/farcaster/README.md @@ -15,15 +15,39 @@ farcaster/ ## Actions -- `account_details`: Get the details of the Farcaster account +- `account_details`: Get the details of the agent's Farcaster account - - Accepts the FID of the Farcaster account to get details for + - Returns the agent's account information + +- `get_user_details`: Get the details of any Farcaster user + + - Look up users by username or FID + - Useful for getting information about other users before interacting - `post_cast`: Create a new Farcaster post - Supports text content up to 280 characters - Supports up to 2 embedded URLs via the optional `embeds` parameter +- `reply_to_cast`: Reply to an existing cast + + - Takes the parent cast hash and reply text + - Supports up to 2 embedded URLs + - Enables conversational interactions on Farcaster + +- `get_feed`: Get a user's casts/feed + + - Retrieve casts from any user (defaults to agent's own casts) + - Configurable limit (1-100 casts) + - Option to include or exclude replies + - Useful for getting context on previous conversations + +- `get_mentions`: Get casts that mention the agent + + - Retrieve notifications where the agent was mentioned + - Enables the agent to respond to users who have tagged them + - Configurable limit (1-100 mentions) + ## Adding New Actions To add new Farcaster actions: @@ -36,9 +60,18 @@ To add new Farcaster actions: The Farcaster provider supports all EVM-compatible networks. +## Environment Variables + +| Variable | Description | +|----------|-------------| +| `NEYNAR_API_KEY` | Your Neynar API key | +| `NEYNAR_MANAGER_SIGNER` | The managed signer UUID for posting | +| `AGENT_FID` | The FID of your agent's Farcaster account | + ## Notes - Requires a Neynar API key. Visit the [Neynar Dashboard](https://dev.neynar.com/) to get your key. - Embeds allow you to attach URLs to casts that will render as rich previews in the Farcaster client +- The `get_mentions` action is useful for building conversational agents that respond to user interactions For more information on the **Farcaster Protocol**, visit [Farcaster Documentation](https://docs.farcaster.xyz/). diff --git a/typescript/agentkit/src/action-providers/farcaster/farcasterActionProvider.test.ts b/typescript/agentkit/src/action-providers/farcaster/farcasterActionProvider.test.ts index 6e160cd4d..d5ec7b539 100644 --- a/typescript/agentkit/src/action-providers/farcaster/farcasterActionProvider.test.ts +++ b/typescript/agentkit/src/action-providers/farcaster/farcasterActionProvider.test.ts @@ -1,5 +1,12 @@ import { FarcasterActionProvider } from "./farcasterActionProvider"; -import { FarcasterAccountDetailsSchema, FarcasterPostCastSchema } from "./schemas"; +import { + FarcasterAccountDetailsSchema, + FarcasterPostCastSchema, + FarcasterGetUserDetailsSchema, + FarcasterReplyCastSchema, + FarcasterGetFeedSchema, + FarcasterGetMentionsSchema, +} from "./schemas"; // Mock fetch globally const mockFetch = jest.fn(); @@ -248,4 +255,250 @@ describe("Farcaster Action Provider", () => { expect(actionProvider.supportsNetwork({ protocolFamily: "solana" })).toBe(false); }); }); + + describe("getUserDetails", () => { + const mockUserResponse = { + users: [ + { + object: "user", + fid: 123, + username: "testuser", + display_name: "Test User", + }, + ], + }; + + it("should successfully retrieve user details by FID", async () => { + mockFetch.mockResolvedValueOnce({ + json: () => Promise.resolve(mockUserResponse), + }); + + const result = await actionProvider.getUserDetails({ fid: 123 }); + + expect(mockFetch).toHaveBeenCalledWith( + "https://api.neynar.com/v2/farcaster/user/bulk?fids=123", + expect.objectContaining({ + method: "GET", + }), + ); + expect(result).toContain("Successfully retrieved Farcaster user details"); + }); + + it("should successfully retrieve user details by username", async () => { + mockFetch.mockResolvedValueOnce({ + json: () => Promise.resolve({ user: mockUserResponse.users[0] }), + }); + + const result = await actionProvider.getUserDetails({ username: "testuser" }); + + expect(mockFetch).toHaveBeenCalledWith( + "https://api.neynar.com/v2/farcaster/user/by_username?username=testuser", + expect.objectContaining({ + method: "GET", + }), + ); + expect(result).toContain("Successfully retrieved Farcaster user details"); + }); + + it("should handle user not found", async () => { + mockFetch.mockResolvedValueOnce({ + json: () => Promise.resolve({ users: [] }), + }); + + const result = await actionProvider.getUserDetails({ fid: 99999 }); + + expect(result).toContain("User not found"); + }); + }); + + describe("replyToCast", () => { + const mockReplyResponse = { + cast: { + hash: "0x456", + text: "This is a reply", + }, + }; + + it("should successfully reply to a cast", async () => { + mockFetch.mockResolvedValueOnce({ + json: () => Promise.resolve(mockReplyResponse), + }); + + const args = { + parentHash: "0x123", + replyText: "This is a reply", + }; + + const result = await actionProvider.replyToCast(args); + + expect(mockFetch).toHaveBeenCalledWith("https://api.neynar.com/v2/farcaster/cast", { + method: "POST", + headers: { + api_key: mockConfig.neynarApiKey, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + signer_uuid: mockConfig.signerUuid, + text: args.replyText, + parent: args.parentHash, + embeds: undefined, + }), + }); + expect(result).toContain("Successfully posted reply to Farcaster"); + }); + + it("should handle errors when replying", async () => { + const error = new Error("Failed to reply"); + mockFetch.mockRejectedValueOnce(error); + + const args = { + parentHash: "0x123", + replyText: "This is a reply", + }; + + const result = await actionProvider.replyToCast(args); + + expect(result).toBe(`Error posting reply to Farcaster:\n${error}`); + }); + }); + + describe("getFeed", () => { + const mockFeedResponse = { + casts: [ + { hash: "0x1", text: "Cast 1" }, + { hash: "0x2", text: "Cast 2" }, + ], + }; + + it("should successfully retrieve feed for agent", async () => { + mockFetch.mockResolvedValueOnce({ + json: () => Promise.resolve(mockFeedResponse), + }); + + const result = await actionProvider.getFeed({ limit: 10, includeReplies: false }); + + expect(mockFetch).toHaveBeenCalledWith( + `https://api.neynar.com/v2/farcaster/feed/user/casts?fid=${mockConfig.agentFid}&limit=10&include_replies=false`, + expect.objectContaining({ + method: "GET", + }), + ); + expect(result).toContain("Successfully retrieved Farcaster feed"); + expect(result).toContain("2 casts"); + }); + + it("should retrieve feed for specified FID", async () => { + mockFetch.mockResolvedValueOnce({ + json: () => Promise.resolve(mockFeedResponse), + }); + + const result = await actionProvider.getFeed({ fid: 456, limit: 25, includeReplies: true }); + + expect(mockFetch).toHaveBeenCalledWith( + "https://api.neynar.com/v2/farcaster/feed/user/casts?fid=456&limit=25&include_replies=true", + expect.objectContaining({ + method: "GET", + }), + ); + expect(result).toContain("Successfully retrieved Farcaster feed"); + }); + }); + + describe("getMentions", () => { + const mockMentionsResponse = { + notifications: [ + { cast: { hash: "0x1", text: "Hey @agent" } }, + { cast: { hash: "0x2", text: "Hello @agent" } }, + ], + }; + + it("should successfully retrieve mentions", async () => { + mockFetch.mockResolvedValueOnce({ + json: () => Promise.resolve(mockMentionsResponse), + }); + + const result = await actionProvider.getMentions({ limit: 10 }); + + expect(mockFetch).toHaveBeenCalledWith( + `https://api.neynar.com/v2/farcaster/notifications?fid=${mockConfig.agentFid}&type=mentions&limit=10`, + expect.objectContaining({ + method: "GET", + }), + ); + expect(result).toContain("Successfully retrieved Farcaster mentions"); + expect(result).toContain("2 mentions"); + }); + + it("should handle errors when retrieving mentions", async () => { + const error = new Error("Failed to get mentions"); + mockFetch.mockRejectedValueOnce(error); + + const result = await actionProvider.getMentions({ limit: 25 }); + + expect(result).toBe(`Error retrieving Farcaster mentions:\n${error}`); + }); + }); +}); + +describe("New Farcaster Schema Tests", () => { + describe("GetUserDetails Schema", () => { + it("should accept username", () => { + const result = FarcasterGetUserDetailsSchema.safeParse({ username: "testuser" }); + expect(result.success).toBe(true); + }); + + it("should accept fid", () => { + const result = FarcasterGetUserDetailsSchema.safeParse({ fid: 123 }); + expect(result.success).toBe(true); + }); + + it("should reject when neither username nor fid provided", () => { + const result = FarcasterGetUserDetailsSchema.safeParse({}); + expect(result.success).toBe(false); + }); + }); + + describe("ReplyCast Schema", () => { + it("should accept valid reply", () => { + const result = FarcasterReplyCastSchema.safeParse({ + parentHash: "0x123", + replyText: "This is a reply", + }); + expect(result.success).toBe(true); + }); + + it("should reject reply over 280 characters", () => { + const result = FarcasterReplyCastSchema.safeParse({ + parentHash: "0x123", + replyText: "a".repeat(281), + }); + expect(result.success).toBe(false); + }); + }); + + describe("GetFeed Schema", () => { + it("should accept valid feed request", () => { + const result = FarcasterGetFeedSchema.safeParse({ + limit: 25, + includeReplies: true, + }); + expect(result.success).toBe(true); + }); + + it("should reject limit over 100", () => { + const result = FarcasterGetFeedSchema.safeParse({ + limit: 101, + }); + expect(result.success).toBe(false); + }); + }); + + describe("GetMentions Schema", () => { + it("should accept valid mentions request", () => { + const result = FarcasterGetMentionsSchema.safeParse({ + limit: 25, + }); + expect(result.success).toBe(true); + }); + }); }); diff --git a/typescript/agentkit/src/action-providers/farcaster/farcasterActionProvider.ts b/typescript/agentkit/src/action-providers/farcaster/farcasterActionProvider.ts index f891aec30..251a00409 100644 --- a/typescript/agentkit/src/action-providers/farcaster/farcasterActionProvider.ts +++ b/typescript/agentkit/src/action-providers/farcaster/farcasterActionProvider.ts @@ -2,7 +2,14 @@ import { z } from "zod"; import { ActionProvider } from "../actionProvider"; import { Network } from "../../network"; import { CreateAction } from "../actionDecorator"; -import { FarcasterAccountDetailsSchema, FarcasterPostCastSchema } from "./schemas"; +import { + FarcasterAccountDetailsSchema, + FarcasterPostCastSchema, + FarcasterGetUserDetailsSchema, + FarcasterReplyCastSchema, + FarcasterGetFeedSchema, + FarcasterGetMentionsSchema, +} from "./schemas"; /** * Configuration options for the FarcasterActionProvider. @@ -144,6 +151,198 @@ A failure response will return a message with the Farcaster API request error: } } + /** + * Gets details for any Farcaster user by username or FID. + * + * @param args - The input arguments containing username or FID. + * @returns A message containing the user's Farcaster account details. + */ + @CreateAction({ + name: "get_user_details", + description: ` +This tool will retrieve the account details for any Farcaster user by their username or FID. +You must provide either a username or FID to look up. + +A successful response will return a message with the API response as a JSON payload: + { "object": "user", "fid": 193, "username": "derek", "display_name": "Derek", ... } + +A failure response will return a message with the error: + Unable to retrieve user details for the specified user. +`, + schema: FarcasterGetUserDetailsSchema, + }) + async getUserDetails(args: z.infer): Promise { + try { + const headers: HeadersInit = { + accept: "application/json", + "x-api-key": this.neynarApiKey, + "x-neynar-experimental": "true", + }; + + let url: string; + if (args.fid) { + url = `https://api.neynar.com/v2/farcaster/user/bulk?fids=${args.fid}`; + } else if (args.username) { + url = `https://api.neynar.com/v2/farcaster/user/by_username?username=${args.username}`; + } else { + return "Error: Either username or fid must be provided"; + } + + const response = await fetch(url, { + method: "GET", + headers, + }); + + const data = await response.json(); + const user = args.fid ? data.users?.[0] : data.user; + + if (!user) { + return `User not found for ${args.fid ? `FID: ${args.fid}` : `username: ${args.username}`}`; + } + + return `Successfully retrieved Farcaster user details:\n${JSON.stringify(user)}`; + } catch (error) { + return `Error retrieving Farcaster user details:\n${error}`; + } + } + + /** + * Replies to a cast on Farcaster. + * + * @param args - The input arguments for the reply action. + * @returns A message indicating the success or failure of the reply. + */ + @CreateAction({ + name: "reply_to_cast", + description: ` +This tool will post a reply to an existing cast on Farcaster. +The tool takes the parent cast hash and the reply text as input. Replies can be maximum 280 characters. +Optionally, up to 2 embeds (links to websites or mini apps) can be attached. + +A successful response will return a message with the API response as a JSON payload: + { "cast": { "hash": "...", "text": "..." } } + +A failure response will return a message with the Farcaster API request error. +`, + schema: FarcasterReplyCastSchema, + }) + async replyToCast(args: z.infer): Promise { + try { + const headers: HeadersInit = { + api_key: this.neynarApiKey, + "Content-Type": "application/json", + }; + + const response = await fetch("https://api.neynar.com/v2/farcaster/cast", { + method: "POST", + headers, + body: JSON.stringify({ + signer_uuid: this.signerUuid, + text: args.replyText, + parent: args.parentHash, + embeds: args.embeds, + }), + }); + + const data = await response.json(); + return `Successfully posted reply to Farcaster:\n${JSON.stringify(data)}`; + } catch (error) { + return `Error posting reply to Farcaster:\n${error}`; + } + } + + /** + * Gets a user's feed/casts from Farcaster. + * + * @param args - The input arguments for getting the feed. + * @returns A message containing the user's casts. + */ + @CreateAction({ + name: "get_feed", + description: ` +This tool will retrieve casts from a user's Farcaster feed. +If no FID is provided, it will retrieve the agent's own casts. +You can specify the number of casts to retrieve (1-100, default: 25) and whether to include replies. + +A successful response will return a message with the casts as a JSON array: + { "casts": [{ "hash": "...", "text": "...", "timestamp": "..." }, ...] } + +A failure response will return a message with the error. +`, + schema: FarcasterGetFeedSchema, + }) + async getFeed(args: z.infer): Promise { + try { + const headers: HeadersInit = { + accept: "application/json", + "x-api-key": this.neynarApiKey, + "x-neynar-experimental": "true", + }; + + const fid = args.fid || this.agentFid; + const limit = args.limit || 25; + const includeReplies = args.includeReplies || false; + + const response = await fetch( + `https://api.neynar.com/v2/farcaster/feed/user/casts?fid=${fid}&limit=${limit}&include_replies=${includeReplies}`, + { + method: "GET", + headers, + }, + ); + + const data = await response.json(); + return `Successfully retrieved Farcaster feed (${data.casts?.length || 0} casts):\n${JSON.stringify(data.casts)}`; + } catch (error) { + return `Error retrieving Farcaster feed:\n${error}`; + } + } + + /** + * Gets mentions of the agent on Farcaster. + * + * @param args - The input arguments for getting mentions. + * @returns A message containing the mentions. + */ + @CreateAction({ + name: "get_mentions", + description: ` +This tool will retrieve casts that mention the agent on Farcaster. +This is useful for the agent to respond to users who have mentioned them. +You can specify the number of mentions to retrieve (1-100, default: 25). + +A successful response will return a message with the mentions as a JSON array: + { "notifications": [{ "cast": { "hash": "...", "text": "...", "author": {...} }, ... }] } + +A failure response will return a message with the error. +`, + schema: FarcasterGetMentionsSchema, + }) + async getMentions(args: z.infer): Promise { + try { + const headers: HeadersInit = { + accept: "application/json", + "x-api-key": this.neynarApiKey, + "x-neynar-experimental": "true", + }; + + const limit = args.limit || 25; + + const response = await fetch( + `https://api.neynar.com/v2/farcaster/notifications?fid=${this.agentFid}&type=mentions&limit=${limit}`, + { + method: "GET", + headers, + }, + ); + + const data = await response.json(); + return `Successfully retrieved Farcaster mentions (${data.notifications?.length || 0} mentions):\n${JSON.stringify(data.notifications)}`; + } catch (error) { + return `Error retrieving Farcaster mentions:\n${error}`; + } + } + /** * Checks if the Farcaster action provider supports the given network. * diff --git a/typescript/agentkit/src/action-providers/farcaster/schemas.ts b/typescript/agentkit/src/action-providers/farcaster/schemas.ts index 2443ceebe..d6545b70b 100644 --- a/typescript/agentkit/src/action-providers/farcaster/schemas.ts +++ b/typescript/agentkit/src/action-providers/farcaster/schemas.ts @@ -25,3 +25,57 @@ export const FarcasterPostCastSchema = z }) .strip() .describe("Input schema for posting a text-based cast"); + +/** + * Input argument schema for getting user details by username or FID. + */ +export const FarcasterGetUserDetailsSchema = z + .object({ + username: z.string().optional().describe("The username of the Farcaster account to look up"), + fid: z.number().optional().describe("The FID of the Farcaster account to look up"), + }) + .refine((data) => data.username || data.fid, { + message: "Either username or fid must be provided", + }) + .describe("Input schema for getting user details by username or FID"); + +/** + * Input argument schema for replying to a cast. + */ +export const FarcasterReplyCastSchema = z + .object({ + parentHash: z.string().describe("The hash of the parent cast to reply to"), + replyText: z.string().max(280, "Reply text must be a maximum of 280 characters."), + embeds: z + .array( + z.object({ + url: z.string().url("Embed URL must be a valid URL"), + }), + ) + .max(2, "Maximum of 2 embeds allowed") + .optional(), + }) + .strip() + .describe("Input schema for replying to a cast"); + +/** + * Input argument schema for getting user's feed/casts. + */ +export const FarcasterGetFeedSchema = z + .object({ + fid: z.number().optional().describe("The FID of the user to get casts for. Defaults to agent's FID if not provided."), + limit: z.number().min(1).max(100).default(25).describe("Number of casts to retrieve (1-100, default: 25)"), + includeReplies: z.boolean().default(false).describe("Whether to include replies in the feed"), + }) + .strip() + .describe("Input schema for getting a user's feed/casts"); + +/** + * Input argument schema for getting mentions. + */ +export const FarcasterGetMentionsSchema = z + .object({ + limit: z.number().min(1).max(100).default(25).describe("Number of mentions to retrieve (1-100, default: 25)"), + }) + .strip() + .describe("Input schema for getting mentions of the agent");