diff --git a/packages/client-twitter/src/post.ts b/packages/client-twitter/src/post.ts index ef8e629de8..97a94f06a1 100644 --- a/packages/client-twitter/src/post.ts +++ b/packages/client-twitter/src/post.ts @@ -32,10 +32,12 @@ const twitterPostTemplate = ` {{postDirections}} # Task: Generate a post in the voice and style and perspective of {{agentName}} @{{twitterUserName}}. -Write a 1-3 sentence post that is {{adjective}} about {{topic}} (without mentioning {{topic}} directly), from the perspective of {{agentName}}. Do not add commentary or acknowledge this request, just write the post. -Your response should not contain any questions. Brief, concise statements only. The total character count MUST be less than {{maxTweetLength}}. No emojis. Use \\n\\n (double spaces) between statements.`; +Write a post that is {{adjective}} about {{topic}} (without mentioning {{topic}} directly), from the perspective of {{agentName}}. Do not add commentary or acknowledge this request, just write the post. +Your response should be 1, 2, or 3 sentences (choose the length at random). +Your response should not contain any questions. Brief, concise statements only. The total character count MUST be less than {{maxTweetLength}}. No emojis. Use \\n\\n (double spaces) between statements if there are multiple statements in your response.`; -export const twitterActionTemplate = ` +export const twitterActionTemplate = + ` # INSTRUCTIONS: Determine actions for {{agentName}} (@{{twitterUserName}}) based on: {{bio}} {{postDirections}} @@ -54,8 +56,7 @@ Actions (respond only with tags): Tweet: {{currentTweet}} -# Respond with qualifying action tags only.` - + postActionResponseFooter; +# Respond with qualifying action tags only.` + postActionResponseFooter; const MAX_TWEET_LENGTH = 240; @@ -92,7 +93,6 @@ function truncateToCompleteSentence( return text.slice(0, maxTweetLength - 3).trim() + "..."; } - export class TwitterPostClient { client: ClientBase; runtime: IAgentRuntime; @@ -101,7 +101,6 @@ export class TwitterPostClient { private lastProcessTime: number = 0; private stopProcessingActions: boolean = false; - async start(postImmediately: boolean = false) { if (!this.client.profile) { await this.client.init(); @@ -110,11 +109,7 @@ export class TwitterPostClient { const generateNewTweetLoop = async () => { const lastPost = await this.runtime.cacheManager.get<{ timestamp: number; - }>( - "twitter/" + - this.twitterUsername + - "/lastPost" - ); + }>("twitter/" + this.twitterUsername + "/lastPost"); const lastPostTimestamp = lastPost?.timestamp ?? 0; const minMinutes = @@ -138,23 +133,29 @@ export class TwitterPostClient { }; const processActionsLoop = async () => { - const actionInterval = parseInt( - this.runtime.getSetting("ACTION_INTERVAL") - ) || 300000; // Default to 5 minutes + const actionInterval = + parseInt(this.runtime.getSetting("ACTION_INTERVAL")) || 300000; // Default to 5 minutes while (!this.stopProcessingActions) { try { const results = await this.processTweetActions(); if (results) { elizaLogger.log(`Processed ${results.length} tweets`); - elizaLogger.log(`Next action processing scheduled in ${actionInterval / 1000} seconds`); + elizaLogger.log( + `Next action processing scheduled in ${actionInterval / 1000} seconds` + ); // Wait for the full interval before next processing - await new Promise(resolve => setTimeout(resolve, actionInterval)); + await new Promise((resolve) => + setTimeout(resolve, actionInterval) + ); } } catch (error) { - elizaLogger.error("Error in action processing loop:", error); + elizaLogger.error( + "Error in action processing loop:", + error + ); // Add exponential backoff on error - await new Promise(resolve => setTimeout(resolve, 30000)); // Wait 30s on error + await new Promise((resolve) => setTimeout(resolve, 30000)); // Wait 30s on error } } }; @@ -179,8 +180,11 @@ export class TwitterPostClient { ); if (enableActionProcessing) { - processActionsLoop().catch(error => { - elizaLogger.error("Fatal error in process actions loop:", error); + processActionsLoop().catch((error) => { + elizaLogger.error( + "Fatal error in process actions loop:", + error + ); }); } else { elizaLogger.log("Action processing loop disabled by configuration"); @@ -216,7 +220,7 @@ export class TwitterPostClient { roomId: roomId, agentId: this.runtime.agentId, content: { - text: topics || '', + text: topics || "", action: "TWEET", }, }, @@ -232,6 +236,8 @@ export class TwitterPostClient { twitterPostTemplate, }); + console.log("twitter context:\n" + context); + elizaLogger.debug("generate post prompt:\n" + context); const newTweetContent = await generateText({ @@ -241,43 +247,48 @@ export class TwitterPostClient { }); // First attempt to clean content - let cleanedContent = ''; + let cleanedContent = ""; // Try parsing as JSON first try { const parsedResponse = JSON.parse(newTweetContent); if (parsedResponse.text) { cleanedContent = parsedResponse.text; - } else if (typeof parsedResponse === 'string') { + } else if (typeof parsedResponse === "string") { cleanedContent = parsedResponse; } } catch (error) { error.linted = true; // make linter happy since catch needs a variable // If not JSON, clean the raw content cleanedContent = newTweetContent - .replace(/^\s*{?\s*"text":\s*"|"\s*}?\s*$/g, '') // Remove JSON-like wrapper - .replace(/^['"](.*)['"]$/g, '$1') // Remove quotes - .replace(/\\"/g, '"') // Unescape quotes - .replace(/\\n/g, '\n') // Unescape newlines + .replace(/^\s*{?\s*"text":\s*"|"\s*}?\s*$/g, "") // Remove JSON-like wrapper + .replace(/^['"](.*)['"]$/g, "$1") // Remove quotes + .replace(/\\"/g, '"') // Unescape quotes + .replace(/\\n/g, "\n") // Unescape newlines .trim(); } if (!cleanedContent) { - elizaLogger.error('Failed to extract valid content from response:', { - rawResponse: newTweetContent, - attempted: 'JSON parsing' - }); + elizaLogger.error( + "Failed to extract valid content from response:", + { + rawResponse: newTweetContent, + attempted: "JSON parsing", + } + ); return; } // Use the helper function to truncate to complete sentence - const content = truncateToCompleteSentence(cleanedContent, MAX_TWEET_LENGTH); + const content = truncateToCompleteSentence( + cleanedContent, + MAX_TWEET_LENGTH + ); const removeQuotes = (str: string) => str.replace(/^['"](.*)['"]$/, "$1"); - const fixNewLines = (str: string) => - str.replaceAll(/\\n/g, "\n"); + const fixNewLines = (str: string) => str.replaceAll(/\\n/g, "\n"); // Final cleaning cleanedContent = removeQuotes(fixNewLines(content)); @@ -294,7 +305,9 @@ export class TwitterPostClient { const result = await this.client.requestQueue.add( async () => - await this.client.twitterClient.sendTweet(cleanedContent) + await this.client.twitterClient.sendTweet( + cleanedContent + ) ); const body = await result.json(); if (!body?.data?.create_tweet?.tweet_results?.result) { @@ -364,26 +377,32 @@ export class TwitterPostClient { } } - private async generateTweetContent(tweetState: any, options?: { - template?: string; - context?: string; - }): Promise { + private async generateTweetContent( + tweetState: any, + options?: { + template?: string; + context?: string; + } + ): Promise { const context = composeContext({ state: tweetState, - template: options?.template || this.runtime.character.templates?.twitterPostTemplate || twitterPostTemplate, + template: + options?.template || + this.runtime.character.templates?.twitterPostTemplate || + twitterPostTemplate, }); const response = await generateText({ runtime: this.runtime, context: options?.context || context, - modelClass: ModelClass.SMALL + modelClass: ModelClass.SMALL, }); console.log("generate tweet content response:\n" + response); // First clean up any markdown and newlines const cleanedResponse = response - .replace(/```json\s*/g, '') // Remove ```json - .replace(/```\s*/g, '') // Remove any remaining ``` + .replace(/```json\s*/g, "") // Remove ```json + .replace(/```\s*/g, "") // Remove any remaining ``` .replaceAll(/\\n/g, "\n") .trim(); @@ -393,8 +412,11 @@ export class TwitterPostClient { if (jsonResponse.text) { return this.trimTweetLength(jsonResponse.text); } - if (typeof jsonResponse === 'object') { - const possibleContent = jsonResponse.content || jsonResponse.message || jsonResponse.response; + if (typeof jsonResponse === "object") { + const possibleContent = + jsonResponse.content || + jsonResponse.message || + jsonResponse.response; if (possibleContent) { return this.trimTweetLength(possibleContent); } @@ -403,7 +425,7 @@ export class TwitterPostClient { error.linted = true; // make linter happy since catch needs a variable // If JSON parsing fails, treat as plain text - elizaLogger.debug('Response is not JSON, treating as plain text'); + elizaLogger.debug("Response is not JSON, treating as plain text"); } // If not JSON or no valid content found, clean the raw text @@ -415,18 +437,20 @@ export class TwitterPostClient { if (text.length <= maxLength) return text; // Try to cut at last sentence - const lastSentence = text.slice(0, maxLength).lastIndexOf('.'); + const lastSentence = text.slice(0, maxLength).lastIndexOf("."); if (lastSentence > 0) { return text.slice(0, lastSentence + 1).trim(); } // Fallback to word boundary - return text.slice(0, text.lastIndexOf(' ', maxLength - 3)).trim() + '...'; + return ( + text.slice(0, text.lastIndexOf(" ", maxLength - 3)).trim() + "..." + ); } private async processTweetActions() { if (this.isProcessing) { - elizaLogger.log('Already processing tweet actions, skipping'); + elizaLogger.log("Already processing tweet actions, skipping"); return null; } @@ -449,11 +473,14 @@ export class TwitterPostClient { for (const tweet of homeTimeline) { try { // Skip if we've already processed this tweet - const memory = await this.runtime.messageManager.getMemoryById( - stringToUuid(tweet.id + "-" + this.runtime.agentId) - ); + const memory = + await this.runtime.messageManager.getMemoryById( + stringToUuid(tweet.id + "-" + this.runtime.agentId) + ); if (memory) { - elizaLogger.log(`Already processed tweet ID: ${tweet.id}`); + elizaLogger.log( + `Already processed tweet ID: ${tweet.id}` + ); continue; } @@ -476,7 +503,10 @@ export class TwitterPostClient { const actionContext = composeContext({ state: tweetState, - template: this.runtime.character.templates?.twitterActionTemplate || twitterActionTemplate, + template: + this.runtime.character.templates + ?.twitterActionTemplate || + twitterActionTemplate, }); const actionResponse = await generateTweetActions({ @@ -486,7 +516,9 @@ export class TwitterPostClient { }); if (!actionResponse) { - elizaLogger.log(`No valid actions generated for tweet ${tweet.id}`); + elizaLogger.log( + `No valid actions generated for tweet ${tweet.id}` + ); continue; } @@ -496,99 +528,144 @@ export class TwitterPostClient { if (actionResponse.like) { try { await this.client.twitterClient.likeTweet(tweet.id); - executedActions.push('like'); + executedActions.push("like"); elizaLogger.log(`Liked tweet ${tweet.id}`); } catch (error) { - elizaLogger.error(`Error liking tweet ${tweet.id}:`, error); + elizaLogger.error( + `Error liking tweet ${tweet.id}:`, + error + ); } } if (actionResponse.retweet) { try { await this.client.twitterClient.retweet(tweet.id); - executedActions.push('retweet'); + executedActions.push("retweet"); elizaLogger.log(`Retweeted tweet ${tweet.id}`); } catch (error) { - elizaLogger.error(`Error retweeting tweet ${tweet.id}:`, error); + elizaLogger.error( + `Error retweeting tweet ${tweet.id}:`, + error + ); } } if (actionResponse.quote) { try { // Build conversation thread for context - const thread = await buildConversationThread(tweet, this.client); + const thread = await buildConversationThread( + tweet, + this.client + ); const formattedConversation = thread - .map((t) => `@${t.username} (${new Date(t.timestamp * 1000).toLocaleString()}): ${t.text}`) + .map( + (t) => + `@${t.username} (${new Date(t.timestamp * 1000).toLocaleString()}): ${t.text}` + ) .join("\n\n"); // Generate image descriptions if present const imageDescriptions = []; if (tweet.photos?.length > 0) { - elizaLogger.log('Processing images in tweet for context'); + elizaLogger.log( + "Processing images in tweet for context" + ); for (const photo of tweet.photos) { const description = await this.runtime - .getService(ServiceType.IMAGE_DESCRIPTION) + .getService( + ServiceType.IMAGE_DESCRIPTION + ) .describeImage(photo.url); imageDescriptions.push(description); } } // Handle quoted tweet if present - let quotedContent = ''; + let quotedContent = ""; if (tweet.quotedStatusId) { try { - const quotedTweet = await this.client.twitterClient.getTweet(tweet.quotedStatusId); + const quotedTweet = + await this.client.twitterClient.getTweet( + tweet.quotedStatusId + ); if (quotedTweet) { quotedContent = `\nQuoted Tweet from @${quotedTweet.username}:\n${quotedTweet.text}`; } } catch (error) { - elizaLogger.error('Error fetching quoted tweet:', error); + elizaLogger.error( + "Error fetching quoted tweet:", + error + ); } } // Compose rich state with all context - const enrichedState = await this.runtime.composeState( - { - userId: this.runtime.agentId, - roomId: stringToUuid(tweet.conversationId + "-" + this.runtime.agentId), - agentId: this.runtime.agentId, - content: { text: tweet.text, action: "QUOTE" } - }, - { - twitterUserName: this.twitterUsername, - currentPost: `From @${tweet.username}: ${tweet.text}`, - formattedConversation, - imageContext: imageDescriptions.length > 0 - ? `\nImages in Tweet:\n${imageDescriptions.map((desc, i) => `Image ${i + 1}: ${desc}`).join('\n')}` - : '', - quotedContent, - } - ); + const enrichedState = + await this.runtime.composeState( + { + userId: this.runtime.agentId, + roomId: stringToUuid( + tweet.conversationId + + "-" + + this.runtime.agentId + ), + agentId: this.runtime.agentId, + content: { + text: tweet.text, + action: "QUOTE", + }, + }, + { + twitterUserName: this.twitterUsername, + currentPost: `From @${tweet.username}: ${tweet.text}`, + formattedConversation, + imageContext: + imageDescriptions.length > 0 + ? `\nImages in Tweet:\n${imageDescriptions.map((desc, i) => `Image ${i + 1}: ${desc}`).join("\n")}` + : "", + quotedContent, + } + ); - const quoteContent = await this.generateTweetContent(enrichedState, { - template: this.runtime.character.templates?.twitterMessageHandlerTemplate || twitterMessageHandlerTemplate - }); + const quoteContent = + await this.generateTweetContent(enrichedState, { + template: + this.runtime.character.templates + ?.twitterMessageHandlerTemplate || + twitterMessageHandlerTemplate, + }); if (!quoteContent) { - elizaLogger.error('Failed to generate valid quote tweet content'); + elizaLogger.error( + "Failed to generate valid quote tweet content" + ); return; } - elizaLogger.log('Generated quote tweet content:', quoteContent); + elizaLogger.log( + "Generated quote tweet content:", + quoteContent + ); // Send the tweet through request queue const result = await this.client.requestQueue.add( - async () => await this.client.twitterClient.sendQuoteTweet( - quoteContent, - tweet.id - ) + async () => + await this.client.twitterClient.sendQuoteTweet( + quoteContent, + tweet.id + ) ); const body = await result.json(); - if (body?.data?.create_tweet?.tweet_results?.result) { - elizaLogger.log('Successfully posted quote tweet'); - executedActions.push('quote'); + if ( + body?.data?.create_tweet?.tweet_results?.result + ) { + elizaLogger.log( + "Successfully posted quote tweet" + ); + executedActions.push("quote"); // Cache generation context for debugging await this.runtime.cacheManager.set( @@ -596,18 +673,31 @@ export class TwitterPostClient { `Context:\n${enrichedState}\n\nGenerated Quote:\n${quoteContent}` ); } else { - elizaLogger.error('Quote tweet creation failed:', body); + elizaLogger.error( + "Quote tweet creation failed:", + body + ); } } catch (error) { - elizaLogger.error('Error in quote tweet generation:', error); + elizaLogger.error( + "Error in quote tweet generation:", + error + ); } } if (actionResponse.reply) { try { - await this.handleTextOnlyReply(tweet, tweetState, executedActions); + await this.handleTextOnlyReply( + tweet, + tweetState, + executedActions + ); } catch (error) { - elizaLogger.error(`Error replying to tweet ${tweet.id}:`, error); + elizaLogger.error( + `Error replying to tweet ${tweet.id}:`, + error + ); } } @@ -643,55 +733,68 @@ export class TwitterPostClient { results.push({ tweetId: tweet.id, parsedActions: actionResponse, - executedActions + executedActions, }); - } catch (error) { - elizaLogger.error(`Error processing tweet ${tweet.id}:`, error); + elizaLogger.error( + `Error processing tweet ${tweet.id}:`, + error + ); continue; } } return results; // Return results array to indicate completion - } catch (error) { - elizaLogger.error('Error in processTweetActions:', error); + elizaLogger.error("Error in processTweetActions:", error); throw error; } finally { this.isProcessing = false; } } - private async handleTextOnlyReply(tweet: Tweet, tweetState: any, executedActions: string[]) { + private async handleTextOnlyReply( + tweet: Tweet, + tweetState: any, + executedActions: string[] + ) { try { // Build conversation thread for context const thread = await buildConversationThread(tweet, this.client); const formattedConversation = thread - .map((t) => `@${t.username} (${new Date(t.timestamp * 1000).toLocaleString()}): ${t.text}`) + .map( + (t) => + `@${t.username} (${new Date(t.timestamp * 1000).toLocaleString()}): ${t.text}` + ) .join("\n\n"); // Generate image descriptions if present const imageDescriptions = []; if (tweet.photos?.length > 0) { - elizaLogger.log('Processing images in tweet for context'); + elizaLogger.log("Processing images in tweet for context"); for (const photo of tweet.photos) { const description = await this.runtime - .getService(ServiceType.IMAGE_DESCRIPTION) + .getService( + ServiceType.IMAGE_DESCRIPTION + ) .describeImage(photo.url); imageDescriptions.push(description); } } // Handle quoted tweet if present - let quotedContent = ''; + let quotedContent = ""; if (tweet.quotedStatusId) { try { - const quotedTweet = await this.client.twitterClient.getTweet(tweet.quotedStatusId); + const quotedTweet = + await this.client.twitterClient.getTweet( + tweet.quotedStatusId + ); if (quotedTweet) { quotedContent = `\nQuoted Tweet from @${quotedTweet.username}:\n${quotedTweet.text}`; } } catch (error) { - elizaLogger.error('Error fetching quoted tweet:', error); + elizaLogger.error("Error fetching quoted tweet:", error); } } @@ -699,46 +802,53 @@ export class TwitterPostClient { const enrichedState = await this.runtime.composeState( { userId: this.runtime.agentId, - roomId: stringToUuid(tweet.conversationId + "-" + this.runtime.agentId), + roomId: stringToUuid( + tweet.conversationId + "-" + this.runtime.agentId + ), agentId: this.runtime.agentId, - content: { text: tweet.text, action: "" } + content: { text: tweet.text, action: "" }, }, { twitterUserName: this.twitterUsername, currentPost: `From @${tweet.username}: ${tweet.text}`, formattedConversation, - imageContext: imageDescriptions.length > 0 - ? `\nImages in Tweet:\n${imageDescriptions.map((desc, i) => `Image ${i + 1}: ${desc}`).join('\n')}` - : '', + imageContext: + imageDescriptions.length > 0 + ? `\nImages in Tweet:\n${imageDescriptions.map((desc, i) => `Image ${i + 1}: ${desc}`).join("\n")}` + : "", quotedContent, } ); // Generate and clean the reply content const replyText = await this.generateTweetContent(enrichedState, { - template: this.runtime.character.templates?.twitterMessageHandlerTemplate || twitterMessageHandlerTemplate + template: + this.runtime.character.templates + ?.twitterMessageHandlerTemplate || + twitterMessageHandlerTemplate, }); if (!replyText) { - elizaLogger.error('Failed to generate valid reply content'); + elizaLogger.error("Failed to generate valid reply content"); return; } - elizaLogger.debug('Final reply text to be sent:', replyText); + elizaLogger.debug("Final reply text to be sent:", replyText); // Send the tweet through request queue const result = await this.client.requestQueue.add( - async () => await this.client.twitterClient.sendTweet( - replyText, - tweet.id - ) + async () => + await this.client.twitterClient.sendTweet( + replyText, + tweet.id + ) ); const body = await result.json(); if (body?.data?.create_tweet?.tweet_results?.result) { - elizaLogger.log('Successfully posted reply tweet'); - executedActions.push('reply'); + elizaLogger.log("Successfully posted reply tweet"); + executedActions.push("reply"); // Cache generation context for debugging await this.runtime.cacheManager.set( @@ -746,10 +856,10 @@ export class TwitterPostClient { `Context:\n${enrichedState}\n\nGenerated Reply:\n${replyText}` ); } else { - elizaLogger.error('Tweet reply creation failed:', body); + elizaLogger.error("Tweet reply creation failed:", body); } } catch (error) { - elizaLogger.error('Error in handleTextOnlyReply:', error); + elizaLogger.error("Error in handleTextOnlyReply:", error); } } diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 92106e50fc..4feef6f662 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -657,6 +657,7 @@ export type Character = { continueMessageHandlerTemplate?: string; evaluationTemplate?: string; twitterSearchTemplate?: string; + twitterActionTemplate?: string; twitterPostTemplate?: string; twitterMessageHandlerTemplate?: string; twitterShouldRespondTemplate?: string; @@ -755,7 +756,6 @@ export type Character = { slack?: { shouldIgnoreBotMessages?: boolean; shouldIgnoreDirectMessages?: boolean; - }; }; @@ -777,7 +777,7 @@ export type Character = { /** Optional NFT prompt */ nft?: { prompt: string; - } + }; }; /**