Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions typescript/.changeset/nice-ducks-search.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@coinbase/agentkit": minor
---

Added media upload support for Twitter and embeds support for Farcaster
6 changes: 5 additions & 1 deletion typescript/agentkit/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -261,7 +261,7 @@ const agent = createReactAgent({
</tr>
<tr>
<td width="200"><code>post_cast</code></td>
<td width="768">Creates a new cast (message) on Farcaster with up to 280 characters.</td>
<td width="768">Creates a new cast (message) on Farcaster with up to 280 characters and support for up to 2 embedded URLs.</td>
</tr>
</table>
</details>
Expand Down Expand Up @@ -362,6 +362,10 @@ const agent = createReactAgent({
<td width="200"><code>post_tweet_reply</code></td>
<td width="768">Creates a reply to an existing tweet using the tweet's unique identifier.</td>
</tr>
<tr>
<td width="200"><code>upload_media</code></td>
<td width="768">Uploads media (images, videos) to Twitter that can be attached to tweets.</td>
</tr>
</table>
</details>
<details>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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/).
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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:
{}
Expand All @@ -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();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
23 changes: 23 additions & 0 deletions typescript/agentkit/src/action-providers/twitter/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand All @@ -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");
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -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<TwitterApiv2>;
let provider: TwitterActionProvider;
let mockUploadMedia: jest.Mock;

beforeEach(() => {
mockClient = {
Expand All @@ -27,7 +30,13 @@ describe("TwitterActionProvider", () => {
tweet: jest.fn(),
} as unknown as jest.Mocked<TwitterApiv2>;

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);
});
Expand Down Expand Up @@ -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));
});
Expand All @@ -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);
});
Expand Down Expand Up @@ -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);
Expand All @@ -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);
Expand Down
Loading
Loading