diff --git a/typescript/.changeset/nice-ducks-search.md b/typescript/.changeset/nice-ducks-search.md new file mode 100644 index 000000000..9e96af6ad --- /dev/null +++ b/typescript/.changeset/nice-ducks-search.md @@ -0,0 +1,5 @@ +--- +"@coinbase/agentkit": minor +--- + +Added media upload support for Twitter and embeds support for Farcaster diff --git a/typescript/agentkit/README.md b/typescript/agentkit/README.md index cd868369e..c62fb7d27 100644 --- a/typescript/agentkit/README.md +++ b/typescript/agentkit/README.md @@ -261,7 +261,7 @@ const agent = createReactAgent({ post_cast - Creates a new cast (message) on Farcaster with up to 280 characters. + Creates a new cast (message) on Farcaster with up to 280 characters and support for up to 2 embedded URLs. @@ -362,6 +362,10 @@ const agent = createReactAgent({ post_tweet_reply Creates a reply to an existing tweet using the tweet's unique identifier. + + upload_media + Uploads media (images, videos) to Twitter that can be attached to tweets. +
diff --git a/typescript/agentkit/src/action-providers/farcaster/README.md b/typescript/agentkit/src/action-providers/farcaster/README.md index 440b992c8..e83a29db8 100644 --- a/typescript/agentkit/src/action-providers/farcaster/README.md +++ b/typescript/agentkit/src/action-providers/farcaster/README.md @@ -21,6 +21,9 @@ farcaster/ - `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 + ## Adding New Actions To add new Farcaster actions: @@ -36,5 +39,6 @@ The Farcaster provider supports all EVM-compatible networks. ## 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 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 6bc7be9d4..6e160cd4d 100644 --- a/typescript/agentkit/src/action-providers/farcaster/farcasterActionProvider.test.ts +++ b/typescript/agentkit/src/action-providers/farcaster/farcasterActionProvider.test.ts @@ -27,6 +27,41 @@ describe("Farcaster Action Provider Input Schemas", () => { expect(result.data).toEqual(validInput); }); + it("should successfully parse valid cast text with embeds", () => { + const validInput = { + castText: "Hello, Farcaster!", + embeds: [{ url: "https://example.com" }], + }; + const result = FarcasterPostCastSchema.safeParse(validInput); + + expect(result.success).toBe(true); + expect(result.data).toEqual(validInput); + }); + + it("should fail when embed URL is invalid", () => { + const invalidInput = { + castText: "Hello, Farcaster!", + embeds: [{ url: "invalid-url" }], + }; + const result = FarcasterPostCastSchema.safeParse(invalidInput); + + expect(result.success).toBe(false); + }); + + it("should fail when there are more than 2 embeds", () => { + const invalidInput = { + castText: "Hello, Farcaster!", + embeds: [ + { url: "https://example1.com" }, + { url: "https://example2.com" }, + { url: "https://example3.com" }, + ], + }; + const result = FarcasterPostCastSchema.safeParse(invalidInput); + + expect(result.success).toBe(false); + }); + it("should fail parsing cast text over 280 characters", () => { const invalidInput = { castText: "a".repeat(281), @@ -123,6 +158,36 @@ describe("Farcaster Action Provider", () => { body: JSON.stringify({ signer_uuid: mockConfig.signerUuid, text: args.castText, + embeds: undefined, + }), + }); + expect(mockFetch).toHaveBeenCalledTimes(1); + expect(result).toContain("Successfully posted cast to Farcaster"); + expect(result).toContain(JSON.stringify(mockCastResponse)); + }); + + it("should successfully post a cast with embeds", async () => { + mockFetch.mockResolvedValueOnce({ + json: () => Promise.resolve(mockCastResponse), + }); + + const args = { + castText: "Hello, Farcaster!", + embeds: [{ url: "https://example.com" }], + }; + + const result = await actionProvider.postCast(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.castText, + embeds: args.embeds, }), }); expect(mockFetch).toHaveBeenCalledTimes(1); diff --git a/typescript/agentkit/src/action-providers/farcaster/farcasterActionProvider.ts b/typescript/agentkit/src/action-providers/farcaster/farcasterActionProvider.ts index 9757291e1..f891aec30 100644 --- a/typescript/agentkit/src/action-providers/farcaster/farcasterActionProvider.ts +++ b/typescript/agentkit/src/action-providers/farcaster/farcasterActionProvider.ts @@ -111,6 +111,7 @@ A failure response will return a message with the Farcaster API request error: name: "post_cast", description: ` This tool will post a cast to Farcaster. The tool takes the text of the cast as input. Casts can be maximum 280 characters. +Optionally, up to 2 embeds (links to websites or mini apps) can be attached to the cast by providing an array of URLs in the embeds parameter. A successful response will return a message with the API response as a JSON payload: {} @@ -133,6 +134,7 @@ A failure response will return a message with the Farcaster API request error: body: JSON.stringify({ signer_uuid: this.signerUuid, text: args.castText, + embeds: args.embeds, }), }); const data = await response.json(); diff --git a/typescript/agentkit/src/action-providers/farcaster/schemas.ts b/typescript/agentkit/src/action-providers/farcaster/schemas.ts index 2170ea598..2443ceebe 100644 --- a/typescript/agentkit/src/action-providers/farcaster/schemas.ts +++ b/typescript/agentkit/src/action-providers/farcaster/schemas.ts @@ -14,6 +14,14 @@ export const FarcasterAccountDetailsSchema = z export const FarcasterPostCastSchema = z .object({ castText: z.string().max(280, "Cast 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 posting a text-based cast"); diff --git a/typescript/agentkit/src/action-providers/twitter/README.md b/typescript/agentkit/src/action-providers/twitter/README.md index fb5c84b1c..a27a34286 100644 --- a/typescript/agentkit/src/action-providers/twitter/README.md +++ b/typescript/agentkit/src/action-providers/twitter/README.md @@ -19,6 +19,7 @@ twitter/ - `account_mentions`: Get mentions for a specified Twitter (X) user - `post_tweet`: Post a new tweet - `post_tweet_reply`: Reply to a tweet +- `upload_media`: Upload media files (images, videos) to Twitter ## Adding New Actions diff --git a/typescript/agentkit/src/action-providers/twitter/schemas.ts b/typescript/agentkit/src/action-providers/twitter/schemas.ts index 67309df9b..d6d3ec275 100644 --- a/typescript/agentkit/src/action-providers/twitter/schemas.ts +++ b/typescript/agentkit/src/action-providers/twitter/schemas.ts @@ -27,6 +27,11 @@ export const TwitterAccountMentionsSchema = z export const TwitterPostTweetSchema = z .object({ tweet: z.string().max(280, "Tweet must be a maximum of 280 characters."), + mediaIds: z + .array(z.string()) + .max(4, "Maximum of 4 media IDs allowed") + .optional() + .describe("Optional array of 1-4 media IDs to attach to the tweet"), }) .strip() .describe("Input schema for posting a tweet"); @@ -40,6 +45,24 @@ export const TwitterPostTweetReplySchema = z tweetReply: z .string() .max(280, "The reply to the tweet which must be a maximum of 280 characters."), + mediaIds: z + .array(z.string()) + .max(4, "Maximum of 4 media IDs allowed") + .optional() + .describe("Optional array of 1-4 media IDs to attach to the tweet"), }) .strip() .describe("Input schema for posting a tweet reply"); + +/** + * Input schema for uploading media. + */ +export const TwitterUploadMediaSchema = z + .object({ + filePath: z + .string() + .min(1, "File path is required.") + .describe("The path to the media file to upload"), + }) + .strip() + .describe("Input schema for uploading media to Twitter"); diff --git a/typescript/agentkit/src/action-providers/twitter/twitterActionProvider.test.ts b/typescript/agentkit/src/action-providers/twitter/twitterActionProvider.test.ts index 87a14d9f1..7f8cfd9b6 100644 --- a/typescript/agentkit/src/action-providers/twitter/twitterActionProvider.test.ts +++ b/typescript/agentkit/src/action-providers/twitter/twitterActionProvider.test.ts @@ -1,4 +1,4 @@ -import { TwitterApi, TwitterApiv2 } from "twitter-api-v2"; +import { TwitterApi, TwitterApiv2, TwitterApiv1 } from "twitter-api-v2"; import { TwitterActionProvider } from "./twitterActionProvider"; import { TweetUserMentionTimelineV2Paginator } from "twitter-api-v2"; @@ -15,10 +15,13 @@ const MOCK_USERNAME = "CDPAgentkit"; const MOCK_TWEET = "Hello, world!"; const MOCK_TWEET_ID = "0123456789012345678"; const MOCK_TWEET_REPLY = "Hello again!"; +const MOCK_MEDIA_ID = "987654321"; +const MOCK_FILE_PATH = "/path/to/image.jpg"; describe("TwitterActionProvider", () => { let mockClient: jest.Mocked; let provider: TwitterActionProvider; + let mockUploadMedia: jest.Mock; beforeEach(() => { mockClient = { @@ -27,7 +30,13 @@ describe("TwitterActionProvider", () => { tweet: jest.fn(), } as unknown as jest.Mocked; + mockUploadMedia = jest.fn(); + jest.spyOn(TwitterApi.prototype, "v2", "get").mockReturnValue(mockClient); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + jest.spyOn(TwitterApi.prototype, "v1", "get").mockReturnValue({ + uploadMedia: mockUploadMedia, + } as unknown as TwitterApiv1); provider = new TwitterActionProvider(MOCK_CONFIG); }); @@ -179,7 +188,18 @@ describe("TwitterActionProvider", () => { it("should successfully post a tweet", async () => { const response = await provider.postTweet({ tweet: MOCK_TWEET }); - expect(mockClient.tweet).toHaveBeenCalledWith(MOCK_TWEET); + expect(mockClient.tweet).toHaveBeenCalledWith(MOCK_TWEET, {}); + expect(response).toContain("Successfully posted to Twitter"); + expect(response).toContain(JSON.stringify(mockResponse)); + }); + + it("should successfully post a tweet with media", async () => { + const mediaIds = [MOCK_MEDIA_ID] as [string]; + const response = await provider.postTweet({ tweet: MOCK_TWEET, mediaIds }); + + expect(mockClient.tweet).toHaveBeenCalledWith(MOCK_TWEET, { + media: { media_ids: mediaIds }, + }); expect(response).toContain("Successfully posted to Twitter"); expect(response).toContain(JSON.stringify(mockResponse)); }); @@ -190,7 +210,7 @@ describe("TwitterActionProvider", () => { const response = await provider.postTweet({ tweet: MOCK_TWEET }); - expect(mockClient.tweet).toHaveBeenCalledWith(MOCK_TWEET); + expect(mockClient.tweet).toHaveBeenCalledWith(MOCK_TWEET, {}); expect(response).toContain("Error posting to Twitter"); expect(response).toContain(error.message); }); @@ -222,6 +242,22 @@ describe("TwitterActionProvider", () => { expect(response).toContain(JSON.stringify(mockResponse)); }); + it("should successfully post a tweet reply with media", async () => { + const mediaIds = [MOCK_MEDIA_ID] as [string]; + const response = await provider.postTweetReply({ + tweetId: MOCK_TWEET_ID, + tweetReply: MOCK_TWEET_REPLY, + mediaIds, + }); + + expect(mockClient.tweet).toHaveBeenCalledWith(MOCK_TWEET_REPLY, { + reply: { in_reply_to_tweet_id: MOCK_TWEET_ID }, + media: { media_ids: mediaIds }, + }); + expect(response).toContain("Successfully posted reply to Twitter"); + expect(response).toContain(JSON.stringify(mockResponse)); + }); + it("should handle errors when posting a tweet reply", async () => { const error = new Error("An error has occurred"); mockClient.tweet.mockRejectedValue(error); @@ -239,6 +275,31 @@ describe("TwitterActionProvider", () => { }); }); + describe("Upload Media Action", () => { + beforeEach(() => { + mockUploadMedia.mockResolvedValue(MOCK_MEDIA_ID); + }); + + it("should successfully upload media", async () => { + const response = await provider.uploadMedia({ filePath: MOCK_FILE_PATH }); + + expect(mockUploadMedia).toHaveBeenCalledWith(MOCK_FILE_PATH); + expect(response).toContain("Successfully uploaded media to Twitter"); + expect(response).toContain(MOCK_MEDIA_ID); + }); + + it("should handle errors when uploading media", async () => { + const error = new Error("Invalid file format"); + mockUploadMedia.mockRejectedValue(error); + + const response = await provider.uploadMedia({ filePath: MOCK_FILE_PATH }); + + expect(mockUploadMedia).toHaveBeenCalledWith(MOCK_FILE_PATH); + expect(response).toContain("Error uploading media to Twitter"); + expect(response).toContain(error.message); + }); + }); + describe("Network Support", () => { it("should always return true for network support", () => { expect(provider.supportsNetwork({ protocolFamily: "evm", networkId: "1" })).toBe(true); diff --git a/typescript/agentkit/src/action-providers/twitter/twitterActionProvider.ts b/typescript/agentkit/src/action-providers/twitter/twitterActionProvider.ts index 76df6d2d9..f2334feab 100644 --- a/typescript/agentkit/src/action-providers/twitter/twitterActionProvider.ts +++ b/typescript/agentkit/src/action-providers/twitter/twitterActionProvider.ts @@ -8,6 +8,7 @@ import { TwitterAccountMentionsSchema, TwitterPostTweetSchema, TwitterPostTweetReplySchema, + TwitterUploadMediaSchema, } from "./schemas"; /** @@ -143,6 +144,8 @@ A failure response will return a message with the Twitter API request error: name: "post_tweet", description: ` This tool will post a tweet on Twitter. The tool takes the text of the tweet as input. Tweets can be maximum 280 characters. +Optionally, up to 4 media items (images, videos) can be attached to the tweet by providing their media IDs in the mediaIds array. +Media IDs are obtained by first uploading the media using the upload_media action. A successful response will return a message with the API response as a JSON payload: {"data": {"text": "hello, world!", "id": "0123456789012345678", "edit_history_tweet_ids": ["0123456789012345678"]}} @@ -153,10 +156,20 @@ A failure response will return a message with the Twitter API request error: }) async postTweet(args: z.infer): Promise { try { - const response = await this.getClient().v2.tweet(args.tweet); + let mediaOptions = {}; + + if (args.mediaIds && args.mediaIds.length > 0) { + // Convert array to tuple format expected by the Twitter API + const mediaIdsTuple = args.mediaIds as unknown as [string, ...string[]]; + mediaOptions = { + media: { media_ids: mediaIdsTuple }, + }; + } + + const response = await this.getClient().v2.tweet(args.tweet, mediaOptions); return `Successfully posted to Twitter:\n${JSON.stringify(response)}`; } catch (error) { - return `Error posting to Twitter:\n${error}`; + return `Error posting to Twitter:\n${error} with media ids: ${args.mediaIds}`; } } @@ -169,7 +182,10 @@ A failure response will return a message with the Twitter API request error: @CreateAction({ name: "post_tweet_reply", description: ` -This tool will post a tweet on Twitter. The tool takes the text of the tweet as input. Tweets can be maximum 280 characters. +This tool will post a reply to a tweet on Twitter. The tool takes the text of the reply and the ID of the tweet to reply to as input. +Replies can be maximum 280 characters. +Optionally, up to 4 media items (images, videos) can be attached to the reply by providing their media IDs in the mediaIds array. +Media IDs can be obtained by first uploading the media using the upload_media action. A successful response will return a message with the API response as a JSON payload: {"data": {"text": "hello, world!", "id": "0123456789012345678", "edit_history_tweet_ids": ["0123456789012345678"]}} @@ -180,9 +196,17 @@ A failure response will return a message with the Twitter API request error: }) async postTweetReply(args: z.infer): Promise { try { - const response = await this.getClient().v2.tweet(args.tweetReply, { + const options: Record = { reply: { in_reply_to_tweet_id: args.tweetId }, - }); + }; + + if (args.mediaIds && args.mediaIds.length > 0) { + // Convert array to tuple format expected by the Twitter API + const mediaIdsTuple = args.mediaIds as unknown as [string, ...string[]]; + options.media = { media_ids: mediaIdsTuple }; + } + + const response = await this.getClient().v2.tweet(args.tweetReply, options); return `Successfully posted reply to Twitter:\n${JSON.stringify(response)}`; } catch (error) { @@ -190,6 +214,33 @@ A failure response will return a message with the Twitter API request error: } } + /** + * Upload media to Twitter. + * + * @param args - The arguments containing the file path + * @returns A JSON string containing the media ID or error message + */ + @CreateAction({ + name: "upload_media", + description: ` +This tool will upload media (images, videos, etc.) to Twitter. + +A successful response will return a message with the media ID: + Successfully uploaded media to Twitter: 1234567890 + +A failure response will return a message with the Twitter API request error: + Error uploading media to Twitter: Invalid file format`, + schema: TwitterUploadMediaSchema, + }) + async uploadMedia(args: z.infer): Promise { + try { + const mediaId = await this.getClient().v1.uploadMedia(args.filePath); + return `Successfully uploaded media to Twitter: ${mediaId}`; + } catch (error) { + return `Error uploading media to Twitter: ${error}`; + } + } + /** * Checks if the Twitter action provider supports the given network. * Twitter actions don't depend on blockchain networks, so always return true.