From b50a5bf7bd0035bb9b691167b6397e0dbdebfde3 Mon Sep 17 00:00:00 2001 From: dorianjanezic <68545109+dorianjanezic@users.noreply.github.com> Date: Thu, 12 Dec 2024 09:20:49 +0100 Subject: [PATCH 1/2] feat: improve Twitter client with action processing --- .env.example | 4 + packages/client-twitter/src/base.ts | 40 +++ packages/client-twitter/src/post.ts | 537 ++++++++++++++++++++++++++-- packages/core/src/generation.ts | 44 +++ packages/core/src/parsing.ts | 36 ++ packages/core/src/types.ts | 7 + 6 files changed, 642 insertions(+), 26 deletions(-) diff --git a/.env.example b/.env.example index 2f4957a2b5..0224ba6c7c 100644 --- a/.env.example +++ b/.env.example @@ -62,6 +62,10 @@ POST_INTERVAL_MIN= # Default: 90 POST_INTERVAL_MAX= # Default: 180 POST_IMMEDIATELY= +# Twitter action processing configuration +ACTION_INTERVAL=300000 # Interval in milliseconds between action processing runs (default: 5 minutes) +ENABLE_ACTION_PROCESSING=false # Set to true to enable the action processing loop + # Feature Flags IMAGE_GEN= # Set to TRUE to enable image generation USE_OPENAI_EMBEDDING= # Set to TRUE for OpenAI/1536, leave blank for local diff --git a/packages/client-twitter/src/base.ts b/packages/client-twitter/src/base.ts index 987a5e2f70..6693c5af0a 100644 --- a/packages/client-twitter/src/base.ts +++ b/packages/client-twitter/src/base.ts @@ -271,6 +271,46 @@ export class ClientBase extends EventEmitter { // }); } + async fetchFeedTimeline(count: number): Promise { + elizaLogger.debug("fetching home timeline"); + const homeTimeline = await this.twitterClient.fetchHomeTimeline(count, []); + return homeTimeline + .filter(tweet => tweet.text || tweet.legacy?.full_text) + .sort((a, b) => { + const timestampA = new Date(a.createdAt ?? a.legacy?.created_at).getTime(); + const timestampB = new Date(b.createdAt ?? b.legacy?.created_at).getTime(); + return timestampB - timestampA; + }) + .slice(0, count) + .map(tweet => + `@${tweet.username || tweet.core?.user_results?.result?.legacy?.screen_name}: ${tweet.text ?? tweet.legacy?.full_text ?? ''}` + ) + .join('\n'); + } + + async fetchTimelineForActions(count: number): Promise { + elizaLogger.debug("fetching timeline for actions"); + const homeTimeline = await this.twitterClient.fetchHomeTimeline(count, []); + + return homeTimeline.map(tweet => ({ + id: tweet.rest_id, + name: tweet.core?.user_results?.result?.legacy?.name, + username: tweet.core?.user_results?.result?.legacy?.screen_name, + text: tweet.legacy?.full_text, + inReplyToStatusId: tweet.legacy?.in_reply_to_status_id_str, + timestamp: new Date(tweet.legacy?.created_at).getTime() / 1000, + userId: tweet.legacy?.user_id_str, + conversationId: tweet.legacy?.conversation_id_str, + permanentUrl: `https://twitter.com/${tweet.core?.user_results?.result?.legacy?.screen_name}/status/${tweet.rest_id}`, + hashtags: tweet.legacy?.entities?.hashtags || [], + mentions: tweet.legacy?.entities?.user_mentions || [], + photos: tweet.legacy?.entities?.media?.filter(media => media.type === "photo") || [], + thread: tweet.thread || [], + urls: tweet.legacy?.entities?.urls || [], + videos: tweet.legacy?.entities?.media?.filter(media => media.type === "video") || [] + })); + } + async fetchSearchTweets( query: string, maxTweets: number, diff --git a/packages/client-twitter/src/post.ts b/packages/client-twitter/src/post.ts index 3c7ff1d08e..62cb2b30ea 100644 --- a/packages/client-twitter/src/post.ts +++ b/packages/client-twitter/src/post.ts @@ -10,6 +10,11 @@ import { } from "@ai16z/eliza"; import { elizaLogger } from "@ai16z/eliza"; import { ClientBase } from "./base.ts"; +import { postActionResponseFooter } from "@ai16z/eliza"; +import { generateTweetActions } from "@ai16z/eliza"; +import { IImageDescriptionService, ServiceType } from "@ai16z/eliza"; +import { buildConversationThread } from "./utils.ts"; +import { twitterMessageHandlerTemplate } from "./interactions.ts"; const twitterPostTemplate = ` # Areas of Expertise @@ -30,21 +35,42 @@ const twitterPostTemplate = ` 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.`; +export const twitterActionTemplate = ` +# INSTRUCTIONS: Determine actions for {{agentName}} (@{{twitterUserName}}) based on: +{{bio}} +{{postDirections}} + +Guidelines: +- Highly selective engagement +- Direct mentions are priority +- Skip: low-effort content, off-topic, repetitive + +Actions (respond only with tags): +[LIKE] - Resonates with interests (9.5/10) +[RETWEET] - Perfect character alignment (9/10) +[QUOTE] - Can add unique value (8/10) +[REPLY] - Memetic opportunity (9/10) + +Tweet: +{{currentTweet}} + +# Respond with qualifying action tags only.` + + postActionResponseFooter; + +const MAX_TWEET_LENGTH = 240; + /** * Truncate text to fit within the Twitter character limit, ensuring it ends at a complete sentence. */ -function truncateToCompleteSentence( - text: string, - maxTweetLength: number -): string { - if (text.length <= maxTweetLength) { +function truncateToCompleteSentence(text: string): string { + if (text.length <= MAX_TWEET_LENGTH) { return text; } // Attempt to truncate at the last period within the limit const truncatedAtPeriod = text.slice( 0, - text.lastIndexOf(".", maxTweetLength) + 1 + text.lastIndexOf(".", MAX_TWEET_LENGTH) + 1 ); if (truncatedAtPeriod.trim().length > 0) { return truncatedAtPeriod.trim(); @@ -53,19 +79,23 @@ function truncateToCompleteSentence( // If no period is found, truncate to the nearest whitespace const truncatedAtSpace = text.slice( 0, - text.lastIndexOf(" ", maxTweetLength) + text.lastIndexOf(" ", MAX_TWEET_LENGTH) ); if (truncatedAtSpace.trim().length > 0) { return truncatedAtSpace.trim() + "..."; } // Fallback: Hard truncate and add ellipsis - return text.slice(0, maxTweetLength - 3).trim() + "..."; + return text.slice(0, MAX_TWEET_LENGTH - 3).trim() + "..."; } export class TwitterPostClient { client: ClientBase; runtime: IAgentRuntime; + private isProcessing: boolean = false; + private lastProcessTime: number = 0; + private stopProcessingActions: boolean = false; + async start(postImmediately: boolean = false) { if (!this.client.profile) { @@ -83,9 +113,9 @@ export class TwitterPostClient { const lastPostTimestamp = lastPost?.timestamp ?? 0; const minMinutes = - parseInt(this.runtime.getSetting("POST_INTERVAL_MIN")) || 90; + parseInt(this.runtime.getSetting("POST_INTERVAL_MIN")) || 1; const maxMinutes = - parseInt(this.runtime.getSetting("POST_INTERVAL_MAX")) || 180; + parseInt(this.runtime.getSetting("POST_INTERVAL_MAX")) || 2; const randomMinutes = Math.floor(Math.random() * (maxMinutes - minMinutes + 1)) + minMinutes; @@ -101,6 +131,31 @@ export class TwitterPostClient { elizaLogger.log(`Next tweet scheduled in ${randomMinutes} minutes`); }; + + + + const processActionsLoop = async () => { + 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`); + // Wait for the full interval before next processing + await new Promise(resolve => setTimeout(resolve, actionInterval)); + } + } catch (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 + } + } + }; + if ( this.runtime.getSetting("POST_IMMEDIATELY") != null && this.runtime.getSetting("POST_IMMEDIATELY") != "" @@ -109,11 +164,23 @@ export class TwitterPostClient { this.runtime.getSetting("POST_IMMEDIATELY") ); } + if (postImmediately) { - this.generateNewTweet(); + await this.generateNewTweet(); } - generateNewTweetLoop(); + // Add check for ENABLE_ACTION_PROCESSING before starting the loop + const enableActionProcessing = parseBooleanFromText( + this.runtime.getSetting("ENABLE_ACTION_PROCESSING") ?? "true" + ); + + if (enableActionProcessing) { + processActionsLoop().catch(error => { + elizaLogger.error("Fatal error in process actions loop:", error); + }); + } else { + elizaLogger.log("Action processing loop disabled by configuration"); + } } constructor(client: ClientBase, runtime: IAgentRuntime) { @@ -136,19 +203,19 @@ export class TwitterPostClient { ); const topics = this.runtime.character.topics.join(", "); + const state = await this.runtime.composeState( { userId: this.runtime.agentId, roomId: roomId, agentId: this.runtime.agentId, content: { - text: topics, - action: "", + text: topics || '', + action: "TWEET", }, }, { twitterUserName: this.client.profile.username, - maxTweetLength: this.runtime.getSetting("MAX_TWEET_LENGTH"), } ); @@ -160,6 +227,7 @@ export class TwitterPostClient { }); elizaLogger.debug("generate post prompt:\n" + context); + console.log("generate post prompt:\n" + context); const newTweetContent = await generateText({ runtime: this.runtime, @@ -167,30 +235,56 @@ export class TwitterPostClient { modelClass: ModelClass.SMALL, }); - // Replace \n with proper line breaks and trim excess spaces - const formattedTweet = newTweetContent - .replaceAll(/\\n/g, "\n") - .trim(); + // First attempt to clean content + let cleanedContent = ''; + + // Try parsing as JSON first + try { + const parsedResponse = JSON.parse(newTweetContent); + if (parsedResponse.text) { + cleanedContent = parsedResponse.text; + } else if (typeof parsedResponse === 'string') { + cleanedContent = parsedResponse; + } + } catch (error) { + // 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 + .trim(); + } + + if (!cleanedContent) { + 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( - formattedTweet, - Number(this.runtime.getSetting("MAX_TWEET_LENGTH")) - ); + const content = truncateToCompleteSentence(cleanedContent); + + const removeQuotes = (str: string) => + str.replace(/^['"](.*)['"]$/, "$1"); + + // Final cleaning + cleanedContent = removeQuotes(content); if (this.runtime.getSetting("TWITTER_DRY_RUN") === "true") { elizaLogger.info( - `Dry run: would have posted tweet: ${content}` + `Dry run: would have posted tweet: ${cleanedContent}` ); return; } try { - elizaLogger.log(`Posting new tweet:\n ${content}`); + elizaLogger.log(`Posting new tweet:\n ${cleanedContent}`); const result = await this.client.requestQueue.add( async () => - await this.client.twitterClient.sendTweet(content) + await this.client.twitterClient.sendTweet(cleanedContent) ); const body = await result.json(); if (!body?.data?.create_tweet?.tweet_results?.result) { @@ -259,4 +353,395 @@ export class TwitterPostClient { elizaLogger.error("Error generating new tweet:", error); } } + + 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, + }); + + const response = await generateText({ + runtime: this.runtime, + context: options?.context || context, + modelClass: ModelClass.SMALL + }); + console.log("generate tweet content response:\n" + response); + + // First clean up any markdown and newlines + let cleanedResponse = response + .replace(/```json\s*/g, '') // Remove ```json + .replace(/```\s*/g, '') // Remove any remaining ``` + .replaceAll(/\\n/g, "\n") + .trim(); + + // Try to parse as JSON first + try { + const jsonResponse = JSON.parse(cleanedResponse); + if (jsonResponse.text) { + return this.trimTweetLength(jsonResponse.text); + } + if (typeof jsonResponse === 'object') { + const possibleContent = jsonResponse.content || jsonResponse.message || jsonResponse.response; + if (possibleContent) { + return this.trimTweetLength(possibleContent); + } + } + } catch (error) { + // If JSON parsing fails, treat 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 + return this.trimTweetLength(cleanedResponse); + } + + // Helper method to ensure tweet length compliance + private trimTweetLength(text: string, maxLength: number = 280): string { + if (text.length <= maxLength) return text; + + // Try to cut at last sentence + 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() + '...'; + } + + private async processTweetActions() { + if (this.isProcessing) { + elizaLogger.log('Already processing tweet actions, skipping'); + return null; + } + + try { + this.isProcessing = true; + this.lastProcessTime = Date.now(); + + elizaLogger.log("Processing tweet actions"); + + await this.runtime.ensureUserExists( + this.runtime.agentId, + this.runtime.getSetting("TWITTER_USERNAME"), + this.runtime.character.name, + "twitter" + ); + + const homeTimeline = await this.client.fetchTimelineForActions(15); + const results = []; + + 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) + ); + if (memory) { + elizaLogger.log(`Already processed tweet ID: ${tweet.id}`); + continue; + } + + const roomId = stringToUuid( + tweet.conversationId + "-" + this.runtime.agentId + ); + + const tweetState = await this.runtime.composeState( + { + userId: this.runtime.agentId, + roomId, + agentId: this.runtime.agentId, + content: { text: "", action: "" }, + }, + { + twitterUserName: this.runtime.getSetting("TWITTER_USERNAME"), + currentTweet: `ID: ${tweet.id}\nFrom: ${tweet.name} (@${tweet.username})\nText: ${tweet.text}`, + } + ); + + const actionContext = composeContext({ + state: tweetState, + template: this.runtime.character.templates?.twitterActionTemplate || twitterActionTemplate, + }); + + const actionResponse = await generateTweetActions({ + runtime: this.runtime, + context: actionContext, + modelClass: ModelClass.SMALL, + }); + + if (!actionResponse) { + elizaLogger.log(`No valid actions generated for tweet ${tweet.id}`); + continue; + } + + const executedActions: string[] = []; + + // Execute actions + if (actionResponse.like) { + try { + await this.client.twitterClient.likeTweet(tweet.id); + executedActions.push('like'); + elizaLogger.log(`Liked tweet ${tweet.id}`); + } catch (error) { + elizaLogger.error(`Error liking tweet ${tweet.id}:`, error); + } + } + + if (actionResponse.retweet) { + try { + await this.client.twitterClient.retweet(tweet.id); + executedActions.push('retweet'); + elizaLogger.log(`Retweeted tweet ${tweet.id}`); + } catch (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 formattedConversation = thread + .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'); + for (const photo of tweet.photos) { + const description = await this.runtime + .getService(ServiceType.IMAGE_DESCRIPTION) + .describeImage(photo.url); + imageDescriptions.push(description); + } + } + + // Handle quoted tweet if present + let quotedContent = ''; + if (tweet.quotedStatusId) { + try { + 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); + } + } + + // 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.runtime.getSetting("TWITTER_USERNAME"), + 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 + }); + + if (!quoteContent) { + elizaLogger.error('Failed to generate valid quote tweet content'); + return; + } + + 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 + ) + ); + + const body = await result.json(); + + 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( + `twitter/quote_generation_${tweet.id}.txt`, + `Context:\n${enrichedState}\n\nGenerated Quote:\n${quoteContent}` + ); + } else { + elizaLogger.error('Quote tweet creation failed:', body); + } + } catch (error) { + elizaLogger.error('Error in quote tweet generation:', error); + } + } + + if (actionResponse.reply) { + try { + await this.handleTextOnlyReply(tweet, tweetState, executedActions); + } catch (error) { + elizaLogger.error(`Error replying to tweet ${tweet.id}:`, error); + } + } + + // Add these checks before creating memory + await this.runtime.ensureRoomExists(roomId); + await this.runtime.ensureUserExists( + stringToUuid(tweet.userId), + tweet.username, + tweet.name, + "twitter" + ); + await this.runtime.ensureParticipantInRoom( + this.runtime.agentId, + roomId + ); + + // Then create the memory + await this.runtime.messageManager.createMemory({ + id: stringToUuid(tweet.id + "-" + this.runtime.agentId), + userId: stringToUuid(tweet.userId), + content: { + text: tweet.text, + url: tweet.permanentUrl, + source: "twitter", + action: executedActions.join(","), + }, + agentId: this.runtime.agentId, + roomId, + embedding: getEmbeddingZeroVector(), + createdAt: tweet.timestamp * 1000, + }); + + results.push({ + tweetId: tweet.id, + parsedActions: actionResponse, + executedActions + }); + + } catch (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); + throw error; + } finally { + this.isProcessing = false; + } + } + + 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}`) + .join("\n\n"); + + // Generate image descriptions if present + const imageDescriptions = []; + if (tweet.photos?.length > 0) { + elizaLogger.log('Processing images in tweet for context'); + for (const photo of tweet.photos) { + const description = await this.runtime + .getService(ServiceType.IMAGE_DESCRIPTION) + .describeImage(photo.url); + imageDescriptions.push(description); + } + } + + // Handle quoted tweet if present + let quotedContent = ''; + if (tweet.quotedStatusId) { + try { + 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); + } + } + + // 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: "" } + }, + { + twitterUserName: this.runtime.getSetting("TWITTER_USERNAME"), + 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, + } + ); + + // Generate and clean the reply content + const replyText = await this.generateTweetContent(enrichedState, { + template: this.runtime.character.templates?.twitterMessageHandlerTemplate || twitterMessageHandlerTemplate + }); + + if (!replyText) { + elizaLogger.error('Failed to generate valid reply content'); + return; + } + + 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 + ) + ); + + const body = await result.json(); + + if (body?.data?.create_tweet?.tweet_results?.result) { + elizaLogger.log('Successfully posted reply tweet'); + executedActions.push('reply'); + + // Cache generation context for debugging + await this.runtime.cacheManager.set( + `twitter/reply_generation_${tweet.id}.txt`, + `Context:\n${enrichedState}\n\nGenerated Reply:\n${replyText}` + ); + } else { + elizaLogger.error('Tweet reply creation failed:', body); + } + } catch (error) { + elizaLogger.error('Error in handleTextOnlyReply:', error); + } + } + + async stop() { + this.stopProcessingActions = true; + } } diff --git a/packages/core/src/generation.ts b/packages/core/src/generation.ts index 12ef211a65..6349359b5a 100644 --- a/packages/core/src/generation.ts +++ b/packages/core/src/generation.ts @@ -21,6 +21,7 @@ import { parseJsonArrayFromText, parseJSONObjectFromText, parseShouldRespondFromText, + parseActionResponseFromText } from "./parsing.ts"; import settings from "./settings.ts"; import { @@ -32,6 +33,7 @@ import { ModelProviderName, ServiceType, SearchResponse, + ActionResponse } from "./types.ts"; import { fal } from "@fal-ai/client"; @@ -1496,3 +1498,45 @@ interface TogetherAIImageResponse { image_type?: string; }>; } + +export async function generateTweetActions({ + runtime, + context, + modelClass, +}: { + runtime: IAgentRuntime; + context: string; + modelClass: string; +}): Promise { + let retryDelay = 1000; + while (true) { + try { + const response = await generateText({ + runtime, + context, + modelClass, + }); + console.debug("Received response from generateText for tweet actions:", response); + const { actions } = parseActionResponseFromText(response.trim()); + if (actions) { + console.debug("Parsed tweet actions:", actions); + return actions; + } else { + elizaLogger.debug("generateTweetActions no valid response"); + } + } catch (error) { + elizaLogger.error("Error in generateTweetActions:", error); + if ( + error instanceof TypeError && + error.message.includes("queueTextCompletion") + ) { + elizaLogger.error( + "TypeError: Cannot read properties of null (reading 'queueTextCompletion')" + ); + } + } + elizaLogger.log(`Retrying in ${retryDelay}ms...`); + await new Promise((resolve) => setTimeout(resolve, retryDelay)); + retryDelay *= 2; + } +} \ No newline at end of file diff --git a/packages/core/src/parsing.ts b/packages/core/src/parsing.ts index 3f7313f54f..cc85352202 100644 --- a/packages/core/src/parsing.ts +++ b/packages/core/src/parsing.ts @@ -1,3 +1,4 @@ +import { ActionResponse } from "./types.ts"; const jsonBlockPattern = /```json\n([\s\S]*?)\n```/; export const messageCompletionFooter = `\nResponse format should be formatted in a JSON block like this: @@ -146,3 +147,38 @@ export function parseJSONObjectFromText( return null; } } + +export const postActionResponseFooter = `Choose any combination of [LIKE], [RETWEET], [QUOTE], and [REPLY] that are appropriate. Each action must be on its own line. Your response must only include the chosen actions.`; + +export const parseActionResponseFromText = (text: string): { actions: ActionResponse } => { + const actions: ActionResponse = { + like: false, + retweet: false, + quote: false, + reply: false + }; + + // Regex patterns + const likePattern = /\[LIKE\]/i; + const retweetPattern = /\[RETWEET\]/i; + const quotePattern = /\[QUOTE\]/i; + const replyPattern = /\[REPLY\]/i; + + // Check with regex + actions.like = likePattern.test(text); + actions.retweet = retweetPattern.test(text); + actions.quote = quotePattern.test(text); + actions.reply = replyPattern.test(text); + + // Also do line by line parsing as backup + const lines = text.split('\n'); + for (const line of lines) { + const trimmed = line.trim(); + if (trimmed === '[LIKE]') actions.like = true; + if (trimmed === '[RETWEET]') actions.retweet = true; + if (trimmed === '[QUOTE]') actions.quote = true; + if (trimmed === '[REPLY]') actions.reply = true; + } + + return { actions }; +}; diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index d29d0b9805..cfe8e77f98 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -1171,3 +1171,10 @@ export type KnowledgeItem = { id: UUID; content: Content; }; + +export interface ActionResponse { + like: boolean; + retweet: boolean; + quote?: boolean; + reply?: boolean; +} From 5fd9b40638ac8a0bfde9df96612ee9f363bcc923 Mon Sep 17 00:00:00 2001 From: dorianjanezic <68545109+dorianjanezic@users.noreply.github.com> Date: Thu, 12 Dec 2024 10:07:19 +0100 Subject: [PATCH 2/2] feat: improve Twitter client with action processing + fix post interval, remove double debug logs, revert truncateToCompleteSentence function --- packages/client-twitter/src/post.ts | 21 ++++++++++++--------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/packages/client-twitter/src/post.ts b/packages/client-twitter/src/post.ts index 62cb2b30ea..51737e8cfe 100644 --- a/packages/client-twitter/src/post.ts +++ b/packages/client-twitter/src/post.ts @@ -62,15 +62,18 @@ const MAX_TWEET_LENGTH = 240; /** * Truncate text to fit within the Twitter character limit, ensuring it ends at a complete sentence. */ -function truncateToCompleteSentence(text: string): string { - if (text.length <= MAX_TWEET_LENGTH) { +function truncateToCompleteSentence( + text: string, + maxTweetLength: number +): string { + if (text.length <= maxTweetLength) { return text; } // Attempt to truncate at the last period within the limit const truncatedAtPeriod = text.slice( 0, - text.lastIndexOf(".", MAX_TWEET_LENGTH) + 1 + text.lastIndexOf(".", maxTweetLength) + 1 ); if (truncatedAtPeriod.trim().length > 0) { return truncatedAtPeriod.trim(); @@ -79,16 +82,17 @@ function truncateToCompleteSentence(text: string): string { // If no period is found, truncate to the nearest whitespace const truncatedAtSpace = text.slice( 0, - text.lastIndexOf(" ", MAX_TWEET_LENGTH) + text.lastIndexOf(" ", maxTweetLength) ); if (truncatedAtSpace.trim().length > 0) { return truncatedAtSpace.trim() + "..."; } // Fallback: Hard truncate and add ellipsis - return text.slice(0, MAX_TWEET_LENGTH - 3).trim() + "..."; + return text.slice(0, maxTweetLength - 3).trim() + "..."; } + export class TwitterPostClient { client: ClientBase; runtime: IAgentRuntime; @@ -113,9 +117,9 @@ export class TwitterPostClient { const lastPostTimestamp = lastPost?.timestamp ?? 0; const minMinutes = - parseInt(this.runtime.getSetting("POST_INTERVAL_MIN")) || 1; + parseInt(this.runtime.getSetting("POST_INTERVAL_MIN")) || 90; const maxMinutes = - parseInt(this.runtime.getSetting("POST_INTERVAL_MAX")) || 2; + parseInt(this.runtime.getSetting("POST_INTERVAL_MAX")) || 180; const randomMinutes = Math.floor(Math.random() * (maxMinutes - minMinutes + 1)) + minMinutes; @@ -227,7 +231,6 @@ export class TwitterPostClient { }); elizaLogger.debug("generate post prompt:\n" + context); - console.log("generate post prompt:\n" + context); const newTweetContent = await generateText({ runtime: this.runtime, @@ -264,7 +267,7 @@ export class TwitterPostClient { } // Use the helper function to truncate to complete sentence - const content = truncateToCompleteSentence(cleanedContent); + const content = truncateToCompleteSentence(cleanedContent, MAX_TWEET_LENGTH); const removeQuotes = (str: string) => str.replace(/^['"](.*)['"]$/, "$1");