diff --git a/agent/package.json b/agent/package.json index fa4f596bad..1f18fa1551 100644 --- a/agent/package.json +++ b/agent/package.json @@ -52,6 +52,7 @@ "@elizaos/plugin-multiversx": "workspace:*", "@elizaos/plugin-near": "workspace:*", "@elizaos/plugin-zksync-era": "workspace:*", + "@elizaos/plugin-twitter": "workspace:*", "@elizaos/plugin-cronoszkevm": "workspace:*", "@elizaos/plugin-3d-generation": "workspace:*", "readline": "1.3.0", diff --git a/packages/plugin-twitter/.npmignore b/packages/plugin-twitter/.npmignore new file mode 100644 index 0000000000..078562ecea --- /dev/null +++ b/packages/plugin-twitter/.npmignore @@ -0,0 +1,6 @@ +* + +!dist/** +!package.json +!readme.md +!tsup.config.ts \ No newline at end of file diff --git a/packages/plugin-twitter/package.json b/packages/plugin-twitter/package.json new file mode 100644 index 0000000000..520ce565f8 --- /dev/null +++ b/packages/plugin-twitter/package.json @@ -0,0 +1,17 @@ +{ + "name": "@elizaos/plugin-twitter", + "version": "0.1.7-alpha.1", + "main": "dist/index.js", + "type": "module", + "types": "dist/index.d.ts", + "dependencies": { + "@elizaos/core": "workspace:*", + "agent-twitter-client": "^1.0.0", + "tsup": "8.3.5" + }, + "scripts": { + "build": "tsup --format esm --dts", + "dev": "tsup --format esm --dts --watch", + "test": "vitest run" + } +} diff --git a/packages/plugin-twitter/src/actions/post.ts b/packages/plugin-twitter/src/actions/post.ts new file mode 100644 index 0000000000..6b737921fc --- /dev/null +++ b/packages/plugin-twitter/src/actions/post.ts @@ -0,0 +1,237 @@ +import { + Action, + IAgentRuntime, + Memory, + State, + composeContext, + elizaLogger, + ModelClass, + formatMessages, + generateObject, +} from "@elizaos/core"; +import { Scraper } from "agent-twitter-client"; +import { tweetTemplate } from "../templates"; +import { isTweetContent, TweetSchema } from "../types"; + +async function composeTweet( + runtime: IAgentRuntime, + _message: Memory, + state?: State +): Promise { + try { + const context = composeContext({ + state, + template: tweetTemplate, + }); + + const tweetContentObject = await generateObject({ + runtime, + context, + modelClass: ModelClass.SMALL, + schema: TweetSchema, + stop: ["\n"], + }); + + if (!isTweetContent(tweetContentObject.object)) { + elizaLogger.error( + "Invalid tweet content:", + tweetContentObject.object + ); + return; + } + + const trimmedContent = tweetContentObject.object.text.trim(); + + // Skip truncation if TWITTER_PREMIUM is true + if ( + process.env.TWITTER_PREMIUM?.toLowerCase() !== "true" && + trimmedContent.length > 180 + ) { + elizaLogger.warn( + `Tweet too long (${trimmedContent.length} chars), truncating...` + ); + return trimmedContent.substring(0, 177) + "..."; + } + + return trimmedContent; + } catch (error) { + elizaLogger.error("Error composing tweet:", error); + throw error; + } +} + +async function postTweet(content: string): Promise { + try { + const scraper = new Scraper(); + const username = process.env.TWITTER_USERNAME; + const password = process.env.TWITTER_PASSWORD; + const email = process.env.TWITTER_EMAIL; + const twitter2faSecret = process.env.TWITTER_2FA_SECRET; + + if (!username || !password) { + elizaLogger.error( + "Twitter credentials not configured in environment" + ); + return false; + } + + // Login with credentials + await scraper.login(username, password, email, twitter2faSecret); + if (!(await scraper.isLoggedIn())) { + elizaLogger.error("Failed to login to Twitter"); + return false; + } + + // Send the tweet + elizaLogger.log("Attempting to send tweet:", content); + const result = await scraper.sendTweet(content); + + const body = await result.json(); + elizaLogger.log("Tweet response:", body); + + // Check for Twitter API errors + if (body.errors) { + const error = body.errors[0]; + elizaLogger.error( + `Twitter API error (${error.code}): ${error.message}` + ); + return false; + } + + // Check for successful tweet creation + if (!body?.data?.create_tweet?.tweet_results?.result) { + elizaLogger.error( + "Failed to post tweet: No tweet result in response" + ); + return false; + } + + return true; + } catch (error) { + // Log the full error details + elizaLogger.error("Error posting tweet:", { + message: error.message, + stack: error.stack, + name: error.name, + cause: error.cause, + }); + return false; + } +} + +export const postAction: Action = { + name: "POST_TWEET", + similes: ["TWEET", "POST", "SEND_TWEET"], + description: "Post a tweet to Twitter", + validate: async ( + runtime: IAgentRuntime, + message: Memory, + state?: State + ) => { + const hasCredentials = + !!process.env.TWITTER_USERNAME && !!process.env.TWITTER_PASSWORD; + elizaLogger.log(`Has credentials: ${hasCredentials}`); + + return hasCredentials; + }, + handler: async ( + runtime: IAgentRuntime, + message: Memory, + state?: State + ): Promise => { + try { + // Generate tweet content using context + const tweetContent = await composeTweet(runtime, message, state); + + if (!tweetContent) { + elizaLogger.error("No content generated for tweet"); + return false; + } + + elizaLogger.log(`Generated tweet content: ${tweetContent}`); + + // Check for dry run mode - explicitly check for string "true" + if ( + process.env.TWITTER_DRY_RUN && + process.env.TWITTER_DRY_RUN.toLowerCase() === "true" + ) { + elizaLogger.info( + `Dry run: would have posted tweet: ${tweetContent}` + ); + return true; + } + + return await postTweet(tweetContent); + } catch (error) { + elizaLogger.error("Error in post action:", error); + return false; + } + }, + examples: [ + [ + { + user: "{{user1}}", + content: { text: "You should tweet that" }, + }, + { + user: "{{agentName}}", + content: { + text: "I'll share this update with my followers right away!", + action: "POST_TWEET", + }, + }, + ], + [ + { + user: "{{user1}}", + content: { text: "Post this tweet" }, + }, + { + user: "{{agentName}}", + content: { + text: "I'll post that as a tweet now.", + action: "POST_TWEET", + }, + }, + ], + [ + { + user: "{{user1}}", + content: { text: "Share that on Twitter" }, + }, + { + user: "{{agentName}}", + content: { + text: "I'll share this message on Twitter.", + action: "POST_TWEET", + }, + }, + ], + [ + { + user: "{{user1}}", + content: { text: "Post that on X" }, + }, + { + user: "{{agentName}}", + content: { + text: "I'll post this message on X right away.", + action: "POST_TWEET", + }, + }, + ], + [ + { + user: "{{user1}}", + content: { text: "You should put that on X dot com" }, + }, + { + user: "{{agentName}}", + content: { + text: "I'll put this message up on X.com now.", + action: "POST_TWEET", + }, + }, + ], + ], +}; diff --git a/packages/plugin-twitter/src/index.ts b/packages/plugin-twitter/src/index.ts new file mode 100644 index 0000000000..ec979236ab --- /dev/null +++ b/packages/plugin-twitter/src/index.ts @@ -0,0 +1,12 @@ +import { Plugin } from "@elizaos/core"; +import { postAction } from "./actions/post"; + +export const twitterPlugin: Plugin = { + name: "twitter", + description: "Twitter integration plugin for posting tweets", + actions: [postAction], + evaluators: [], + providers: [], +}; + +export default twitterPlugin; diff --git a/packages/plugin-twitter/src/templates.ts b/packages/plugin-twitter/src/templates.ts new file mode 100644 index 0000000000..4578396bce --- /dev/null +++ b/packages/plugin-twitter/src/templates.ts @@ -0,0 +1,22 @@ +export const tweetTemplate = ` +# Context +{{recentMessages}} + +# Topics +{{topics}} + +# Post Directions +{{postDirections}} + +# Recent interactions between {{agentName}} and other users: +{{recentPostInteractions}} + +# Task +Generate a tweet that: +1. Relates to the recent conversation or requested topic +2. Matches the character's style and voice +3. Is concise and engaging +4. Must be UNDER 180 characters (this is a strict requirement) +5. Speaks from the perspective of {{agentName}} + +Generate only the tweet text, no other commentary.`; diff --git a/packages/plugin-twitter/src/types.ts b/packages/plugin-twitter/src/types.ts new file mode 100644 index 0000000000..1f4537b0ac --- /dev/null +++ b/packages/plugin-twitter/src/types.ts @@ -0,0 +1,13 @@ +import { z } from "zod"; + +export interface TweetContent { + text: string; +} + +export const TweetSchema = z.object({ + text: z.string().describe("The text of the tweet"), +}); + +export const isTweetContent = (obj: any): obj is TweetContent => { + return TweetSchema.safeParse(obj).success; +}; diff --git a/packages/plugin-twitter/tsconfig.json b/packages/plugin-twitter/tsconfig.json new file mode 100644 index 0000000000..834c4dce26 --- /dev/null +++ b/packages/plugin-twitter/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/plugin-twitter/tsup.config.ts b/packages/plugin-twitter/tsup.config.ts new file mode 100644 index 0000000000..430573c247 --- /dev/null +++ b/packages/plugin-twitter/tsup.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from "tsup"; + +export default defineConfig({ + entry: ["src/index.ts"], + outDir: "dist", + sourcemap: true, + clean: true, + format: ["esm"], + external: ["dotenv", "fs", "path", "https", "http", "agentkeepalive"], +});