From 49d36d0fd17225a650245b89062f60762a35b8cb Mon Sep 17 00:00:00 2001 From: Christopher Gerber Date: Thu, 30 Jan 2025 16:39:56 -0800 Subject: [PATCH] first pass migrating twitter actions --- .../src/action_providers/twitter/README.md | 0 .../src/action_providers/twitter/index.js | 0 .../src/action_providers/twitter/schemas.ts | 45 ++++ .../twitter/twitterActionProvider.test.ts | 230 ++++++++++++++++++ .../twitter/twitterActionProvider.ts | 207 ++++++++++++++++ 5 files changed, 482 insertions(+) create mode 100644 cdp-agentkit-core/typescript/src/action_providers/twitter/README.md create mode 100644 cdp-agentkit-core/typescript/src/action_providers/twitter/index.js create mode 100644 cdp-agentkit-core/typescript/src/action_providers/twitter/schemas.ts create mode 100644 cdp-agentkit-core/typescript/src/action_providers/twitter/twitterActionProvider.test.ts create mode 100644 cdp-agentkit-core/typescript/src/action_providers/twitter/twitterActionProvider.ts diff --git a/cdp-agentkit-core/typescript/src/action_providers/twitter/README.md b/cdp-agentkit-core/typescript/src/action_providers/twitter/README.md new file mode 100644 index 000000000..e69de29bb diff --git a/cdp-agentkit-core/typescript/src/action_providers/twitter/index.js b/cdp-agentkit-core/typescript/src/action_providers/twitter/index.js new file mode 100644 index 000000000..e69de29bb diff --git a/cdp-agentkit-core/typescript/src/action_providers/twitter/schemas.ts b/cdp-agentkit-core/typescript/src/action_providers/twitter/schemas.ts new file mode 100644 index 000000000..134cd1b47 --- /dev/null +++ b/cdp-agentkit-core/typescript/src/action_providers/twitter/schemas.ts @@ -0,0 +1,45 @@ +import { z } from "zod"; + +/** + * Input schema for retrieving account details. + */ +export const AccountDetailsSchema = z + .object({}) + .strip() + .describe("Input schema for retrieving account details"); + +/** + * Input schema for retrieving account mentions. + */ +export const AccountMentionsSchema = z + .object({ + userId: z + .string() + .min(1, "Account ID is required.") + .describe("The Twitter (X) user id to return mentions for"), + }) + .strip() + .describe("Input schema for retrieving account mentions"); + +/** + * Input schema for posting a tweet. + */ +export const PostTweetSchema = z + .object({ + tweet: z.string().max(280, "Tweet must be a maximum of 280 characters."), + }) + .strip() + .describe("Input schema for posting a tweet"); + +/** + * Input schema for posting a tweet reply. + */ +export const PostTweetReplySchema = z + .object({ + tweetId: z.string().describe("The id of the tweet to reply to"), + tweetReply: z + .string() + .max(280, "The reply to the tweet which must be a maximum of 280 characters."), + }) + .strip() + .describe("Input schema for posting a tweet reply"); diff --git a/cdp-agentkit-core/typescript/src/action_providers/twitter/twitterActionProvider.test.ts b/cdp-agentkit-core/typescript/src/action_providers/twitter/twitterActionProvider.test.ts new file mode 100644 index 000000000..6ad49eabe --- /dev/null +++ b/cdp-agentkit-core/typescript/src/action_providers/twitter/twitterActionProvider.test.ts @@ -0,0 +1,230 @@ +import { TwitterApi, TwitterApiv2 } from "twitter-api-v2"; +import { TwitterActionProvider } from "./twitterActionProvider"; +import { TweetUserMentionTimelineV2Paginator } from "twitter-api-v2"; + +const MOCK_CONFIG = { + apiKey: "test-api-key", + apiSecret: "test-api-secret", + accessToken: "test-access-token", + accessTokenSecret: "test-access-token-secret", +}; + +const MOCK_ID = "1853889445319331840"; +const MOCK_NAME = "CDP Agentkit"; +const MOCK_USERNAME = "CDPAgentkit"; +const MOCK_TWEET = "Hello, world!"; +const MOCK_TWEET_ID = "0123456789012345678"; +const MOCK_TWEET_REPLY = "Hello again!"; + +describe("TwitterActionProvider", () => { + let mockApi: jest.Mocked; + let mockClient: jest.Mocked; + let provider: TwitterActionProvider; + + beforeEach(() => { + // Setup mock client + mockClient = { + me: jest.fn(), + userMentionTimeline: jest.fn(), + tweet: jest.fn(), + } as unknown as jest.Mocked; + + mockApi = { + get v2() { + return mockClient; + }, + } as unknown as jest.Mocked; + + // Mock the TwitterApi constructor + jest.spyOn(TwitterApi.prototype, 'v2', 'get').mockReturnValue(mockClient); + + provider = new TwitterActionProvider(MOCK_CONFIG); + }); + + describe("Constructor", () => { + it("should initialize with config values", () => { + expect(() => new TwitterActionProvider(MOCK_CONFIG)).not.toThrow(); + }); + + it("should initialize with environment variables", () => { + process.env.TWITTER_API_KEY = MOCK_CONFIG.apiKey; + process.env.TWITTER_API_SECRET = MOCK_CONFIG.apiSecret; + process.env.TWITTER_ACCESS_TOKEN = MOCK_CONFIG.accessToken; + process.env.TWITTER_ACCESS_TOKEN_SECRET = MOCK_CONFIG.accessTokenSecret; + + expect(() => new TwitterActionProvider()).not.toThrow(); + }); + + it("should throw error if no config or env vars", () => { + delete process.env.TWITTER_API_KEY; + delete process.env.TWITTER_API_SECRET; + delete process.env.TWITTER_ACCESS_TOKEN; + delete process.env.TWITTER_ACCESS_TOKEN_SECRET; + + expect(() => new TwitterActionProvider()).toThrow("Twitter API Key is not configured."); + }); + }); + + describe("Account Details Action", () => { + const mockResponse = { + data: { + id: MOCK_ID, + name: MOCK_NAME, + username: MOCK_USERNAME, + }, + }; + + beforeEach(() => { + mockClient.me.mockResolvedValue(mockResponse); + }); + + it("should successfully retrieve account details", async () => { + const response = await provider.accountDetails({}); + + expect(mockClient.me).toHaveBeenCalled(); + expect(response).toContain("Successfully retrieved authenticated user account details"); + expect(response).toContain(JSON.stringify({ ...mockResponse, data: { ...mockResponse.data, url: `https://x.com/${MOCK_USERNAME}` } })); + }); + + it("should handle errors when retrieving account details", async () => { + const error = new Error("An error has occurred"); + mockClient.me.mockRejectedValue(error); + + const response = await provider.accountDetails({}); + + expect(mockClient.me).toHaveBeenCalled(); + expect(response).toContain("Error retrieving authenticated user account details"); + expect(response).toContain(error.message); + }); + }); + + describe("Account Mentions Action", () => { + const mockResponse = { + _realData: { + data: [ + { + id: MOCK_TWEET_ID, + text: "@CDPAgentkit please reply!", + }, + ], + }, + data: [ + { + id: MOCK_TWEET_ID, + text: "@CDPAgentkit please reply!", + }, + ], + meta: {}, + _endpoint: {}, + tweets: [], + getItemArray: () => [], + refreshInstanceFromResult: () => mockResponse, + } as unknown as TweetUserMentionTimelineV2Paginator; + + beforeEach(() => { + mockClient.userMentionTimeline.mockResolvedValue(mockResponse); + }); + + it("should successfully retrieve account mentions", async () => { + const response = await provider.accountMentions({ userId: MOCK_ID }); + + expect(mockClient.userMentionTimeline).toHaveBeenCalledWith(MOCK_ID); + expect(response).toContain("Successfully retrieved account mentions"); + expect(response).toContain(JSON.stringify(mockResponse)); + }); + + it("should handle errors when retrieving mentions", async () => { + const error = new Error("An error has occurred"); + mockClient.userMentionTimeline.mockRejectedValue(error); + + const response = await provider.accountMentions({ userId: MOCK_ID }); + + expect(mockClient.userMentionTimeline).toHaveBeenCalledWith(MOCK_ID); + expect(response).toContain("Error retrieving authenticated account mentions"); + expect(response).toContain(error.message); + }); + }); + + describe("Post Tweet Action", () => { + const mockResponse = { + data: { + id: MOCK_TWEET_ID, + text: MOCK_TWEET, + edit_history_tweet_ids: [MOCK_TWEET_ID], + }, + }; + + beforeEach(() => { + mockClient.tweet.mockResolvedValue(mockResponse); + }); + + it("should successfully post a tweet", async () => { + const response = await provider.postTweet({ tweet: MOCK_TWEET }); + + expect(mockClient.tweet).toHaveBeenCalledWith(MOCK_TWEET); + expect(response).toContain("Successfully posted to Twitter"); + expect(response).toContain(JSON.stringify(mockResponse)); + }); + + it("should handle errors when posting a tweet", async () => { + const error = new Error("An error has occurred"); + mockClient.tweet.mockRejectedValue(error); + + const response = await provider.postTweet({ tweet: MOCK_TWEET }); + + expect(mockClient.tweet).toHaveBeenCalledWith(MOCK_TWEET); + expect(response).toContain("Error posting to Twitter"); + expect(response).toContain(error.message); + }); + }); + + describe("Post Tweet Reply Action", () => { + const mockResponse = { + data: { + id: MOCK_TWEET_ID, + text: MOCK_TWEET_REPLY, + edit_history_tweet_ids: [MOCK_TWEET_ID], + }, + }; + + beforeEach(() => { + mockClient.tweet.mockResolvedValue(mockResponse); + }); + + it("should successfully post a tweet reply", async () => { + const response = await provider.postTweetReply({ + tweetId: MOCK_TWEET_ID, + tweetReply: MOCK_TWEET_REPLY, + }); + + expect(mockClient.tweet).toHaveBeenCalledWith(MOCK_TWEET_REPLY, { + reply: { in_reply_to_tweet_id: MOCK_TWEET_ID }, + }); + 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); + + const response = await provider.postTweetReply({ + tweetId: MOCK_TWEET_ID, + tweetReply: MOCK_TWEET_REPLY, + }); + + expect(mockClient.tweet).toHaveBeenCalledWith(MOCK_TWEET_REPLY, { + reply: { in_reply_to_tweet_id: MOCK_TWEET_ID }, + }); + expect(response).toContain("Error posting reply 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); + expect(provider.supportsNetwork({ protocolFamily: "solana", networkId: "2" })).toBe(true); + }); + }); +}); diff --git a/cdp-agentkit-core/typescript/src/action_providers/twitter/twitterActionProvider.ts b/cdp-agentkit-core/typescript/src/action_providers/twitter/twitterActionProvider.ts new file mode 100644 index 000000000..1edb6bc18 --- /dev/null +++ b/cdp-agentkit-core/typescript/src/action_providers/twitter/twitterActionProvider.ts @@ -0,0 +1,207 @@ +import { z } from "zod"; +import { ActionProvider } from "../action_provider"; +import { CreateAction } from "../action_decorator"; +import { TwitterApi, TwitterApiTokens } from "twitter-api-v2"; +import { Network } from "../../wallet_providers/wallet_provider"; +import { + AccountDetailsSchema, + AccountMentionsSchema, + PostTweetSchema, + PostTweetReplySchema, +} from "./schemas"; + +/** + * Configuration options for the TwitterActionProvider. + */ +export interface TwitterActionProviderConfig { + /** + * Twitter API Key + */ + apiKey?: string; + + /** + * Twitter API Secret + */ + apiSecret?: string; + + /** + * Twitter Access Token + */ + accessToken?: string; + + /** + * Twitter Access Token Secret + */ + accessTokenSecret?: string; +} + +/** + * TwitterActionProvider is an action provider for Twitter (X) interactions. + */ +export class TwitterActionProvider extends ActionProvider { + private readonly apiKey: string; + private readonly apiSecret: string; + private readonly accessToken: string; + private readonly accessTokenSecret: string; + private readonly client: TwitterApi; + + /** + * Constructor for the TwitterActionProvider class. + * @param config - The configuration options for the TwitterActionProvider + */ + constructor(config: TwitterActionProviderConfig = {}) { + super("twitter", []); + + if (config.apiKey) { + this.apiKey = config.apiKey; + } else if (process.env.TWITTER_API_KEY) { + this.apiKey = process.env.TWITTER_API_KEY; + } else { + throw new Error("Twitter API Key is not configured."); + } + + if (config.apiSecret) { + this.apiSecret = config.apiSecret; + } else if (process.env.TWITTER_API_SECRET) { + this.apiSecret = process.env.TWITTER_API_SECRET; + } else { + throw new Error("Twitter API Secret is not configured."); + } + + if (config.accessToken) { + this.accessToken = config.accessToken; + } else if (process.env.TWITTER_ACCESS_TOKEN) { + this.accessToken = process.env.TWITTER_ACCESS_TOKEN; + } else { + throw new Error("Twitter Access Token is not configured."); + } + + if (config.accessTokenSecret) { + this.accessTokenSecret = config.accessTokenSecret; + } else if (process.env.TWITTER_ACCESS_TOKEN_SECRET) { + this.accessTokenSecret = process.env.TWITTER_ACCESS_TOKEN_SECRET; + } else { + throw new Error("Twitter Access Token Secret is not configured."); + } + + this.client = new TwitterApi({ + appKey: this.apiKey, + appSecret: this.apiSecret, + accessToken: this.accessToken, + accessSecret: this.accessTokenSecret, + } as TwitterApiTokens); + } + + /** + * Get account details for the currently authenticated Twitter (X) user. + */ + @CreateAction({ + name: "account_details", + description: ` +This tool will return account details for the currently authenticated Twitter (X) user context. + +A successful response will return a message with the api response as a json payload: + {"data": {"id": "1853889445319331840", "name": "CDP AgentKit", "username": "CDPAgentKit"}} + +A failure response will return a message with a Twitter API request error: + Error retrieving authenticated user account: 429 Too Many Requests`, + schema: AccountDetailsSchema, + }) + async accountDetails(_: z.infer): Promise { + try { + const response = await this.client.v2.me(); + response.data.url = `https://x.com/${response.data.username}`; + return `Successfully retrieved authenticated user account details:\n${JSON.stringify( + response, + )}`; + } catch (error) { + return `Error retrieving authenticated user account details: ${error}`; + } + } + + /** + * Get mentions for a specified Twitter (X) user. + */ + @CreateAction({ + name: "account_mentions", + description: ` +This tool will return mentions for the specified Twitter (X) user id. + +A successful response will return a message with the API response as a JSON payload: + {"data": [{"id": "1857479287504584856", "text": "@CDPAgentKit reply"}]} + +A failure response will return a message with the Twitter API request error: + Error retrieving user mentions: 429 Too Many Requests`, + schema: AccountMentionsSchema, + }) + async accountMentions(args: z.infer): Promise { + try { + const response = await this.client.v2.userMentionTimeline(args.userId); + return `Successfully retrieved account mentions:\n${JSON.stringify(response)}`; + } catch (error) { + return `Error retrieving authenticated account mentions: ${error}`; + } + } + + /** + * Post a tweet on Twitter (X). + */ + @CreateAction({ + 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. + +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"]}} + +A failure response will return a message with the Twitter API request error: + You are not allowed to create a Tweet with duplicate content.`, + schema: PostTweetSchema, + }) + async postTweet(args: z.infer): Promise { + try { + const response = await this.client.v2.tweet(args.tweet); + return `Successfully posted to Twitter:\n${JSON.stringify(response)}`; + } catch (error) { + return `Error posting to Twitter:\n${error}`; + } + } + + /** + * Post a reply to a tweet on Twitter (X). + */ + @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. + +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"]}} + +A failure response will return a message with the Twitter API request error: + You are not allowed to create a Tweet with duplicate content.`, + schema: PostTweetReplySchema, + }) + async postTweetReply(args: z.infer): Promise { + try { + const response = await this.client.v2.tweet(args.tweetReply, { + reply: { in_reply_to_tweet_id: args.tweetId }, + }); + + return `Successfully posted reply to Twitter:\n${JSON.stringify(response)}`; + } catch (error) { + return `Error posting reply 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. + */ + supportsNetwork(_: Network): boolean { + return true; + } +} + +export const twitterActionProvider = (config: TwitterActionProviderConfig = {}) => + new TwitterActionProvider(config);