Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: handle long tweet #1339

Merged
merged 8 commits into from
Dec 27, 2024
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
311 changes: 214 additions & 97 deletions packages/client-twitter/src/post.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
ModelClass,
stringToUuid,
parseBooleanFromText,
UUID,
} from "@elizaos/core";
import { elizaLogger } from "@elizaos/core";
import { ClientBase } from "./base.ts";
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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.
*/
Expand Down Expand Up @@ -330,20 +495,25 @@ 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");

const fixNewLines = (str: string) => str.replaceAll(/\\n/g, "\n");

// Final cleaning
cleanedContent = removeQuotes(fixNewLines(content));
cleanedContent = removeQuotes(fixNewLines(cleanedContent));

if (this.isDryRun) {
elizaLogger.info(
Expand All @@ -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);
}
Expand Down Expand Up @@ -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");

Expand All @@ -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);
Expand Down
Loading