diff --git a/packages/client-twitter/src/post.ts b/packages/client-twitter/src/post.ts index 4634e01778..cd7b8b9d6e 100644 --- a/packages/client-twitter/src/post.ts +++ b/packages/client-twitter/src/post.ts @@ -7,6 +7,7 @@ import { ModelClass, stringToUuid, parseBooleanFromText, + UUID, } from "@elizaos/core"; import { elizaLogger } from "@elizaos/core"; import { ClientBase } from "./base.ts"; @@ -71,25 +72,26 @@ function truncateToCompleteSentence( } // Attempt to truncate at the last period within the limit - const truncatedAtPeriod = text.slice( - 0, - text.lastIndexOf(".", maxTweetLength) + 1 - ); - if (truncatedAtPeriod.trim().length > 0) { - return truncatedAtPeriod.trim(); + const lastPeriodIndex = text.lastIndexOf(".", maxTweetLength - 1); + if (lastPeriodIndex !== -1) { + const truncatedAtPeriod = text.slice(0, lastPeriodIndex + 1).trim(); + if (truncatedAtPeriod.length > 0) { + return truncatedAtPeriod; + } } - // If no period is found, truncate to the nearest whitespace - const truncatedAtSpace = text.slice( - 0, - text.lastIndexOf(" ", maxTweetLength) - ); - if (truncatedAtSpace.trim().length > 0) { - return truncatedAtSpace.trim() + "..."; + // If no period, truncate to the nearest whitespace within the limit + const lastSpaceIndex = text.lastIndexOf(" ", maxTweetLength - 1); + if (lastSpaceIndex !== -1) { + const truncatedAtSpace = text.slice(0, lastSpaceIndex).trim(); + if (truncatedAtSpace.length > 0) { + return truncatedAtSpace + "..."; + } } // Fallback: Hard truncate and add ellipsis - return text.slice(0, maxTweetLength - 3).trim() + "..."; + const hardTruncated = text.slice(0, maxTweetLength - 3).trim(); + return hardTruncated + "..."; } export class TwitterPostClient { @@ -248,6 +250,169 @@ export class TwitterPostClient { } } + createTweetObject( + tweetResult: any, + client: any, + twitterUsername: string + ): Tweet { + return { + id: tweetResult.rest_id, + name: client.profile.screenName, + username: client.profile.username, + text: tweetResult.legacy.full_text, + conversationId: tweetResult.legacy.conversation_id_str, + createdAt: tweetResult.legacy.created_at, + timestamp: new Date(tweetResult.legacy.created_at).getTime(), + userId: client.profile.id, + inReplyToStatusId: tweetResult.legacy.in_reply_to_status_id_str, + permanentUrl: `https://twitter.com/${twitterUsername}/status/${tweetResult.rest_id}`, + hashtags: [], + mentions: [], + photos: [], + thread: [], + urls: [], + videos: [], + } as Tweet; + } + + async processAndCacheTweet( + runtime: IAgentRuntime, + client: ClientBase, + tweet: Tweet, + roomId: UUID, + newTweetContent: string + ) { + // Cache the last post details + await runtime.cacheManager.set( + `twitter/${client.profile.username}/lastPost`, + { + id: tweet.id, + timestamp: Date.now(), + } + ); + + // Cache the tweet + await client.cacheTweet(tweet); + + // Log the posted tweet + elizaLogger.log(`Tweet posted:\n ${tweet.permanentUrl}`); + + // Ensure the room and participant exist + await runtime.ensureRoomExists(roomId); + await runtime.ensureParticipantInRoom(runtime.agentId, roomId); + + // Create a memory for the tweet + await runtime.messageManager.createMemory({ + id: stringToUuid(tweet.id + "-" + runtime.agentId), + userId: runtime.agentId, + agentId: runtime.agentId, + content: { + text: newTweetContent.trim(), + url: tweet.permanentUrl, + source: "twitter", + }, + roomId, + embedding: getEmbeddingZeroVector(), + createdAt: tweet.timestamp, + }); + } + + async handleNoteTweet( + client: ClientBase, + runtime: IAgentRuntime, + content: string, + tweetId?: string + ) { + try { + const noteTweetResult = await client.requestQueue.add( + async () => + await client.twitterClient.sendNoteTweet(content, tweetId) + ); + + if (noteTweetResult.errors && noteTweetResult.errors.length > 0) { + // Note Tweet failed due to authorization. Falling back to standard Tweet. + const truncateContent = truncateToCompleteSentence( + content, + parseInt(runtime.getSetting("MAX_TWEET_LENGTH")) || + DEFAULT_MAX_TWEET_LENGTH + ); + return await this.sendStandardTweet( + client, + truncateContent, + tweetId + ); + } else { + return noteTweetResult.data.notetweet_create.tweet_results + .result; + } + } catch (error) { + throw new Error(`Note Tweet failed: ${error}`); + } + } + + async sendStandardTweet( + client: ClientBase, + content: string, + tweetId?: string + ) { + try { + const standardTweetResult = await client.requestQueue.add( + async () => + await client.twitterClient.sendTweet(content, tweetId) + ); + const body = await standardTweetResult.json(); + if (!body?.data?.create_tweet?.tweet_results?.result) { + console.error("Error sending tweet; Bad response:", body); + return; + } + return body.data.create_tweet.tweet_results.result; + } catch (error) { + elizaLogger.error("Error sending standard Tweet:", error); + throw error; + } + } + + async postTweet( + runtime: IAgentRuntime, + client: ClientBase, + cleanedContent: string, + roomId: UUID, + newTweetContent: string, + twitterUsername: string + ) { + try { + elizaLogger.log(`Posting new tweet:\n`); + + let result; + + if (cleanedContent.length > DEFAULT_MAX_TWEET_LENGTH) { + result = await this.handleNoteTweet( + client, + runtime, + cleanedContent + ); + } else { + result = await this.sendStandardTweet(client, cleanedContent); + } + + const tweet = this.createTweetObject( + result, + client, + twitterUsername + ); + + await this.processAndCacheTweet( + runtime, + client, + tweet, + roomId, + newTweetContent + ); + } catch (error) { + elizaLogger.error("Error sending tweet:", error); + } + } + /** * Generates and posts a new tweet. If isDryRun is true, only logs what would have been posted. */ @@ -330,12 +495,17 @@ export class TwitterPostClient { return; } - // Use the helper function to truncate to complete sentence - const content = truncateToCompleteSentence( - cleanedContent, - parseInt(this.runtime.getSetting("MAX_TWEET_LENGTH")) || - DEFAULT_MAX_TWEET_LENGTH + // Truncate the content to the maximum tweet length specified in the environment settings, ensuring the truncation respects sentence boundaries. + const maxTweetLength = parseInt( + this.runtime.getSetting("MAX_TWEET_LENGTH"), + 10 ); + if (maxTweetLength) { + cleanedContent = truncateToCompleteSentence( + cleanedContent, + maxTweetLength + ); + } const removeQuotes = (str: string) => str.replace(/^['"](.*)['"]$/, "$1"); @@ -343,7 +513,7 @@ export class TwitterPostClient { const fixNewLines = (str: string) => str.replaceAll(/\\n/g, "\n"); // Final cleaning - cleanedContent = removeQuotes(fixNewLines(content)); + cleanedContent = removeQuotes(fixNewLines(cleanedContent)); if (this.isDryRun) { elizaLogger.info( @@ -354,73 +524,14 @@ export class TwitterPostClient { try { elizaLogger.log(`Posting new tweet:\n ${cleanedContent}`); - - const result = await this.client.requestQueue.add( - async () => - await this.client.twitterClient.sendTweet( - cleanedContent - ) - ); - const body = await result.json(); - if (!body?.data?.create_tweet?.tweet_results?.result) { - console.error("Error sending tweet; Bad response:", body); - return; - } - const tweetResult = body.data.create_tweet.tweet_results.result; - - const tweet = { - id: tweetResult.rest_id, - name: this.client.profile.screenName, - username: this.client.profile.username, - text: tweetResult.legacy.full_text, - conversationId: tweetResult.legacy.conversation_id_str, - createdAt: tweetResult.legacy.created_at, - timestamp: new Date( - tweetResult.legacy.created_at - ).getTime(), - userId: this.client.profile.id, - inReplyToStatusId: - tweetResult.legacy.in_reply_to_status_id_str, - permanentUrl: `https://twitter.com/${this.twitterUsername}/status/${tweetResult.rest_id}`, - hashtags: [], - mentions: [], - photos: [], - thread: [], - urls: [], - videos: [], - } as Tweet; - - await this.runtime.cacheManager.set( - `twitter/${this.client.profile.username}/lastPost`, - { - id: tweet.id, - timestamp: Date.now(), - } - ); - - await this.client.cacheTweet(tweet); - - elizaLogger.log(`Tweet posted:\n ${tweet.permanentUrl}`); - - await this.runtime.ensureRoomExists(roomId); - await this.runtime.ensureParticipantInRoom( - this.runtime.agentId, - roomId - ); - - await this.runtime.messageManager.createMemory({ - id: stringToUuid(tweet.id + "-" + this.runtime.agentId), - userId: this.runtime.agentId, - agentId: this.runtime.agentId, - content: { - text: newTweetContent.trim(), - url: tweet.permanentUrl, - source: "twitter", - }, + this.postTweet( + this.runtime, + this.client, + cleanedContent, roomId, - embedding: getEmbeddingZeroVector(), - createdAt: tweet.timestamp, - }); + newTweetContent, + this.twitterUsername + ); } catch (error) { elizaLogger.error("Error sending tweet:", error); } @@ -935,18 +1046,24 @@ export class TwitterPostClient { 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 - ) - ); + let result; - const body = await result.json(); + if (replyText.length > DEFAULT_MAX_TWEET_LENGTH) { + result = await this.handleNoteTweet( + this.client, + this.runtime, + replyText, + tweet.id + ); + } else { + result = await this.sendStandardTweet( + this.client, + replyText, + tweet.id + ); + } - if (body?.data?.create_tweet?.tweet_results?.result) { + if (result) { elizaLogger.log("Successfully posted reply tweet"); executedActions.push("reply"); @@ -956,7 +1073,7 @@ 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"); } } catch (error) { elizaLogger.error("Error in handleTextOnlyReply:", error);