diff --git a/packages/client-twitter/src/base.ts b/packages/client-twitter/src/base.ts index 1b04485d6d2..ed4f8481496 100644 --- a/packages/client-twitter/src/base.ts +++ b/packages/client-twitter/src/base.ts @@ -16,6 +16,7 @@ import { Tweet, } from "agent-twitter-client"; import { EventEmitter } from "events"; +import { TwitterConfig } from "./environment.ts"; export function extractAnswer(text: string): string { const startIndex = text.indexOf("Answer: ") + 8; @@ -85,6 +86,7 @@ export class ClientBase extends EventEmitter { static _twitterClients: { [accountIdentifier: string]: Scraper } = {}; twitterClient: Scraper; runtime: IAgentRuntime; + twitterConfig: TwitterConfig; directions: string; lastCheckedTweetId: bigint | null = null; imageDescriptionService: IImageDescriptionService; @@ -134,10 +136,11 @@ export class ClientBase extends EventEmitter { ); } - constructor(runtime: IAgentRuntime) { + constructor(runtime: IAgentRuntime, twitterConfig:TwitterConfig) { super(); this.runtime = runtime; - const username = this.runtime.getSetting("TWITTER_USERNAME"); + this.twitterConfig = twitterConfig; + const username = twitterConfig.TWITTER_USERNAME; if (ClientBase._twitterClients[username]) { this.twitterClient = ClientBase._twitterClients[username]; } else { @@ -153,15 +156,11 @@ export class ClientBase extends EventEmitter { } async init() { - const username = this.runtime.getSetting("TWITTER_USERNAME"); - const password = this.runtime.getSetting("TWITTER_PASSWORD"); - const email = this.runtime.getSetting("TWITTER_EMAIL"); - let retries = parseInt( - this.runtime.getSetting("TWITTER_RETRY_LIMIT") || "5", - 10 - ); - const twitter2faSecret = - this.runtime.getSetting("TWITTER_2FA_SECRET") || undefined; + const username = this.twitterConfig.TWITTER_USERNAME; + const password = this.twitterConfig.TWITTER_PASSWORD; + const email = this.twitterConfig.TWITTER_EMAIL; + let retries = this.twitterConfig.TWITTER_RETRY_LIMIT + const twitter2faSecret = this.twitterConfig.TWITTER_2FA_SECRET; if (!username) { throw new Error("Twitter username not configured"); @@ -314,7 +313,7 @@ export class ClientBase extends EventEmitter { async fetchTimelineForActions(count: number): Promise { elizaLogger.debug("fetching timeline for actions"); - const agentUsername = this.runtime.getSetting("TWITTER_USERNAME"); + const agentUsername = this.twitterConfig.TWITTER_USERNAME const homeTimeline = await this.twitterClient.fetchHomeTimeline( count, [] @@ -510,7 +509,7 @@ export class ClientBase extends EventEmitter { } const timeline = await this.fetchHomeTimeline(cachedTimeline ? 10 : 50); - const username = this.runtime.getSetting("TWITTER_USERNAME"); + const username = this.twitterConfig.TWITTER_USERNAME; // Get the most recent 20 mentions and interactions const mentionsAndInteractions = await this.fetchSearchTweets( diff --git a/packages/client-twitter/src/environment.ts b/packages/client-twitter/src/environment.ts index a5a5bbf82e8..8ff2fb454ed 100644 --- a/packages/client-twitter/src/environment.ts +++ b/packages/client-twitter/src/environment.ts @@ -1,34 +1,100 @@ -import { IAgentRuntime } from "@elizaos/core"; +import { parseBooleanFromText, IAgentRuntime } from "@elizaos/core"; import { z } from "zod"; - export const DEFAULT_MAX_TWEET_LENGTH = 280; +const twitterUsernameSchema = z.string() + .min(1) + .max(15) + .regex(/^[A-Za-z][A-Za-z0-9_]*[A-Za-z0-9]$|^[A-Za-z]$/, 'Invalid Twitter username format'); + export const twitterEnvSchema = z.object({ - TWITTER_DRY_RUN: z - .string() - .transform((val) => val.toLowerCase() === "true"), + TWITTER_DRY_RUN: z.boolean(), TWITTER_USERNAME: z.string().min(1, "Twitter username is required"), TWITTER_PASSWORD: z.string().min(1, "Twitter password is required"), TWITTER_EMAIL: z.string().email("Valid Twitter email is required"), - MAX_TWEET_LENGTH: z + MAX_TWEET_LENGTH: z.number().int().default(DEFAULT_MAX_TWEET_LENGTH), + TWITTER_SEARCH_ENABLE: z.boolean().default(false), + TWITTER_2FA_SECRET: z.string(), + TWITTER_RETRY_LIMIT: z.number().int(), + TWITTER_POLL_INTERVAL: z.number().int(), + TWITTER_TARGET_USERS: z.array(twitterUsernameSchema).default([]), + // I guess it's possible to do the transformation with zod + // not sure it's preferable, maybe a readability issue + // since more people will know js/ts than zod + /* + z .string() - .pipe(z.coerce.number().min(0).int()) - .default(DEFAULT_MAX_TWEET_LENGTH.toString()), + .transform((val) => val.trim()) + .pipe( + z.string() + .transform((val) => + val ? val.split(',').map((u) => u.trim()).filter(Boolean) : [] + ) + .pipe( + z.array( + z.string() + .min(1) + .max(15) + .regex( + /^[A-Za-z][A-Za-z0-9_]*[A-Za-z0-9]$|^[A-Za-z]$/, + 'Invalid Twitter username format' + ) + ) + ) + .transform((users) => users.join(',')) + ) + .optional() + .default(''), + */ + POST_INTERVAL_MIN: z.number().int(), + POST_INTERVAL_MAX: z.number().int(), + ENABLE_ACTION_PROCESSING: z.boolean(), + ACTION_INTERVAL: z.number().int(), + POST_IMMEDIATELY: z.boolean(), }); export type TwitterConfig = z.infer; +function parseTargetUsers(targetUsersStr?:string | null): string[] { + if (!targetUsersStr?.trim()) { + return []; + } + + return targetUsersStr + .split(',') + .map(user => user.trim()) + .filter(Boolean); // Remove empty usernames + /* + .filter(user => { + // Twitter username validation (basic example) + return user && /^[A-Za-z0-9_]{1,15}$/.test(user); + }); + */ +} + +function safeParseInt(value: string | undefined | null, defaultValue: number): number { + if (!value) return defaultValue; + const parsed = parseInt(value, 10); + return isNaN(parsed) ? defaultValue : Math.max(1, parsed); +} + +// This also is organized to serve as a point of documentation for the client +// most of the inputs from the framework (env/character) + +// we also do a lot of typing/parsing here +// so we can do it once and only once per character export async function validateTwitterConfig( runtime: IAgentRuntime ): Promise { try { const twitterConfig = { TWITTER_DRY_RUN: - runtime.getSetting("TWITTER_DRY_RUN") || - process.env.TWITTER_DRY_RUN || - "false", + parseBooleanFromText( + runtime.getSetting("TWITTER_DRY_RUN") || + process.env.TWITTER_DRY_RUN + ) ?? false, // parseBooleanFromText return null if "", map "" to false TWITTER_USERNAME: - runtime.getSetting("TWITTER_USERNAME") || + runtime.getSetting ("TWITTER_USERNAME") || process.env.TWITTER_USERNAME, TWITTER_PASSWORD: runtime.getSetting("TWITTER_PASSWORD") || @@ -36,10 +102,59 @@ export async function validateTwitterConfig( TWITTER_EMAIL: runtime.getSetting("TWITTER_EMAIL") || process.env.TWITTER_EMAIL, - MAX_TWEET_LENGTH: - runtime.getSetting("MAX_TWEET_LENGTH") || - process.env.MAX_TWEET_LENGTH || - DEFAULT_MAX_TWEET_LENGTH.toString(), + MAX_TWEET_LENGTH: // number as string? + safeParseInt( + runtime.getSetting("MAX_TWEET_LENGTH") || + process.env.MAX_TWEET_LENGTH + , DEFAULT_MAX_TWEET_LENGTH), + TWITTER_SEARCH_ENABLE: // bool + parseBooleanFromText( + runtime.getSetting("TWITTER_SEARCH_ENABLE") || + process.env.TWITTER_SEARCH_ENABLE + ) ?? false, + TWITTER_2FA_SECRET: // string passthru + runtime.getSetting("TWITTER_2FA_SECRET") || + process.env.TWITTER_2FA_SECRET || "", + TWITTER_RETRY_LIMIT: // int + safeParseInt( + runtime.getSetting("TWITTER_RETRY_LIMIT") || + process.env.TWITTER_RETRY_LIMIT + , 5), + TWITTER_POLL_INTERVAL: // int in seconds + safeParseInt( + runtime.getSetting("TWITTER_POLL_INTERVAL") || + process.env.TWITTER_POLL_INTERVAL + , 120), // 2m + TWITTER_TARGET_USERS: // comma separated string + parseTargetUsers( + runtime.getSetting("TWITTER_TARGET_USERS") || + process.env.TWITTER_TARGET_USERS + ), + POST_INTERVAL_MIN: // int in minutes + safeParseInt( + runtime.getSetting("POST_INTERVAL_MIN") || + process.env.POST_INTERVAL_MIN + , 90), // 1.5 hours + POST_INTERVAL_MAX: // int in minutes + safeParseInt( + runtime.getSetting("POST_INTERVAL_MAX") || + process.env.POST_INTERVAL_MAX + , 180), // 3 hours + ENABLE_ACTION_PROCESSING: // bool + parseBooleanFromText( + runtime.getSetting("ENABLE_ACTION_PROCESSING") || + process.env.ENABLE_ACTION_PROCESSING + ) ?? false, + ACTION_INTERVAL: // int in minutes (min 1m) + safeParseInt( + runtime.getSetting("ACTION_INTERVAL") || + process.env.ACTION_INTERVAL + , 5), // 5 minutes + POST_IMMEDIATELY: // bool + parseBooleanFromText( + runtime.getSetting("POST_IMMEDIATELY") || + process.env.POST_IMMEDIATELY + ) ?? false, }; return twitterEnvSchema.parse(twitterConfig); diff --git a/packages/client-twitter/src/index.ts b/packages/client-twitter/src/index.ts index 3692525a240..0da22e7d6e3 100644 --- a/packages/client-twitter/src/index.ts +++ b/packages/client-twitter/src/index.ts @@ -1,6 +1,6 @@ import { Client, elizaLogger, IAgentRuntime } from "@elizaos/core"; import { ClientBase } from "./base.ts"; -import { validateTwitterConfig } from "./environment.ts"; +import { validateTwitterConfig, TwitterConfig } from "./environment.ts"; import { TwitterInteractionClient } from "./interactions.ts"; import { TwitterPostClient } from "./post.ts"; import { TwitterSearchClient } from "./search.ts"; @@ -10,11 +10,11 @@ class TwitterManager { post: TwitterPostClient; search: TwitterSearchClient; interaction: TwitterInteractionClient; - constructor(runtime: IAgentRuntime, enableSearch: boolean) { - this.client = new ClientBase(runtime); + constructor(runtime: IAgentRuntime, twitterConfig:TwitterConfig) { + this.client = new ClientBase(runtime, twitterConfig); this.post = new TwitterPostClient(this.client, runtime); - if (enableSearch) { + if (twitterConfig.TWITTER_SEARCH_ENABLE) { // this searches topics from character file elizaLogger.warn("Twitter/X client running in a mode that:"); elizaLogger.warn("1. violates consent of random users"); @@ -30,11 +30,11 @@ class TwitterManager { export const TwitterClientInterface: Client = { async start(runtime: IAgentRuntime) { - await validateTwitterConfig(runtime); + const twitterConfig:TwitterConfig = await validateTwitterConfig(runtime); elizaLogger.log("Twitter client started"); - const manager = new TwitterManager(runtime, runtime.getSetting("TWITTER_SEARCH_ENABLE").toLowerCase() === "true"); + const manager = new TwitterManager(runtime, twitterConfig); await manager.client.init(); @@ -45,8 +45,6 @@ export const TwitterClientInterface: Client = { await manager.interaction.start(); - await manager.search?.start(); - return manager; }, async stop(_runtime: IAgentRuntime) { diff --git a/packages/client-twitter/src/interactions.ts b/packages/client-twitter/src/interactions.ts index 438445ecbeb..3274cd32308 100644 --- a/packages/client-twitter/src/interactions.ts +++ b/packages/client-twitter/src/interactions.ts @@ -100,9 +100,8 @@ export class TwitterInteractionClient { this.handleTwitterInteractions(); setTimeout( handleTwitterInteractionsLoop, - Number( - this.runtime.getSetting("TWITTER_POLL_INTERVAL") || 120 - ) * 1000 // Default to 2 minutes + // Defaults to 2 minutes + this.client.twitterConfig.TWITTER_POLL_INTERVAL * 1000 ); }; handleTwitterInteractionsLoop(); @@ -110,8 +109,6 @@ export class TwitterInteractionClient { async handleTwitterInteractions() { elizaLogger.log("Checking Twitter interactions"); - // Read from environment variable, fallback to default list if not set - const targetUsersStr = this.runtime.getSetting("TWITTER_TARGET_USERS"); const twitterUsername = this.client.profile.username; try { @@ -130,11 +127,8 @@ export class TwitterInteractionClient { ); let uniqueTweetCandidates = [...mentionCandidates]; // Only process target users if configured - if (targetUsersStr && targetUsersStr.trim()) { - const TARGET_USERS = targetUsersStr - .split(",") - .map((u) => u.trim()) - .filter((u) => u.length > 0); // Filter out empty strings after split + if (this.client.twitterConfig.TWITTER_TARGET_USERS.length) { + const TARGET_USERS = this.client.twitterConfig.TWITTER_TARGET_USERS; elizaLogger.log("Processing target users:", TARGET_USERS); @@ -347,7 +341,7 @@ export class TwitterInteractionClient { let state = await this.runtime.composeState(message, { twitterClient: this.client.twitterClient, - twitterUserName: this.runtime.getSetting("TWITTER_USERNAME"), + twitterUserName: this.client.twitterConfig.TWITTER_USERNAME, currentPost, formattedConversation, }); @@ -383,18 +377,8 @@ export class TwitterInteractionClient { this.client.saveRequestMessage(message, state); } - // 1. Get the raw target users string from settings - const targetUsersStr = this.runtime.getSetting("TWITTER_TARGET_USERS"); - - // 2. Process the string to get valid usernames - const validTargetUsersStr = - targetUsersStr && targetUsersStr.trim() - ? targetUsersStr - .split(",") // Split by commas: "user1,user2" -> ["user1", "user2"] - .map((u) => u.trim()) // Remove whitespace: [" user1 ", "user2 "] -> ["user1", "user2"] - .filter((u) => u.length > 0) - .join(",") - : ""; + // get usernames into str + const validTargetUsersStr = this.client.twitterConfig.TWITTER_TARGET_USERS.join(","); const shouldRespondContext = composeContext({ state, @@ -450,7 +434,7 @@ export class TwitterInteractionClient { this.client, response, message.roomId, - this.runtime.getSetting("TWITTER_USERNAME"), + this.client.twitterConfig.TWITTER_USERNAME, tweet.id ); return memories; diff --git a/packages/client-twitter/src/post.ts b/packages/client-twitter/src/post.ts index cd7b8b9d6ec..41466c5ba3d 100644 --- a/packages/client-twitter/src/post.ts +++ b/packages/client-twitter/src/post.ts @@ -6,7 +6,6 @@ import { IAgentRuntime, ModelClass, stringToUuid, - parseBooleanFromText, UUID, } from "@elizaos/core"; import { elizaLogger } from "@elizaos/core"; @@ -106,10 +105,8 @@ export class TwitterPostClient { constructor(client: ClientBase, runtime: IAgentRuntime) { this.client = client; this.runtime = runtime; - this.twitterUsername = runtime.getSetting("TWITTER_USERNAME"); - this.isDryRun = parseBooleanFromText( - runtime.getSetting("TWITTER_DRY_RUN") ?? "false" - ); + this.twitterUsername = this.client.twitterConfig.TWITTER_USERNAME; + this.isDryRun = this.client.twitterConfig.TWITTER_DRY_RUN // Log configuration on initialization elizaLogger.log("Twitter Client Configuration:"); @@ -118,34 +115,34 @@ export class TwitterPostClient { `- Dry Run Mode: ${this.isDryRun ? "enabled" : "disabled"}` ); elizaLogger.log( - `- Post Interval: ${runtime.getSetting("POST_INTERVAL_MIN") || "90"}-${runtime.getSetting("POST_INTERVAL_MAX") || "180"} minutes` + `- Post Interval: ${this.client.twitterConfig.POST_INTERVAL_MIN}-${this.client.twitterConfig.POST_INTERVAL_MAX} minutes` ); elizaLogger.log( - `- Action Processing: ${parseBooleanFromText(runtime.getSetting("ENABLE_ACTION_PROCESSING") ?? "false") ? "enabled" : "disabled"}` + `- Action Processing: ${this.client.twitterConfig.ENABLE_ACTION_PROCESSING ? "enabled" : "disabled"}` ); elizaLogger.log( - `- Action Interval: ${(parseInt(runtime.getSetting("ACTION_INTERVAL") ?? "300000") / 1000).toFixed(0)} seconds` + `- Action Interval: ${this.client.twitterConfig.ACTION_INTERVAL} seconds` ); elizaLogger.log( - `- Post Immediately: ${parseBooleanFromText(runtime.getSetting("POST_IMMEDIATELY") ?? "false") ? "enabled" : "disabled"}` + `- Post Immediately: ${this.client.twitterConfig.POST_IMMEDIATELY ? "enabled" : "disabled"}` ); elizaLogger.log( - `- Search Enabled: ${parseBooleanFromText(runtime.getSetting("TWITTER_SEARCH_ENABLE") ?? "false") ? "enabled" : "disabled"}` + `- Search Enabled: ${this.client.twitterConfig.TWITTER_SEARCH_ENABLE ? "enabled" : "disabled"}` ); - const targetUsers = runtime.getSetting("TWITTER_TARGET_USERS"); + const targetUsers = this.client.twitterConfig.TWITTER_TARGET_USERS; if (targetUsers) { elizaLogger.log(`- Target Users: ${targetUsers}`); } if (this.isDryRun) { elizaLogger.log( - "Twitter client initialized in dry run mode - no actual tweets will be posted" + "Twitter client initialized in dry run mode - no actual tweets should be posted" ); } } - async start(postImmediately: boolean = false) { + async start() { if (!this.client.profile) { await this.client.init(); } @@ -156,10 +153,8 @@ export class TwitterPostClient { }>("twitter/" + this.twitterUsername + "/lastPost"); const lastPostTimestamp = lastPost?.timestamp ?? 0; - const minMinutes = - parseInt(this.runtime.getSetting("POST_INTERVAL_MIN")) || 90; - const maxMinutes = - parseInt(this.runtime.getSetting("POST_INTERVAL_MAX")) || 180; + const minMinutes = this.client.twitterConfig.POST_INTERVAL_MIN; + const maxMinutes = this.client.twitterConfig.POST_INTERVAL_MAX; const randomMinutes = Math.floor(Math.random() * (maxMinutes - minMinutes + 1)) + minMinutes; @@ -177,8 +172,7 @@ export class TwitterPostClient { }; const processActionsLoop = async () => { - const actionInterval = - parseInt(this.runtime.getSetting("ACTION_INTERVAL")) || 300000; // Default to 5 minutes + const actionInterval = this.client.twitterConfig.ACTION_INTERVAL; // Defaults to 5 minutes while (!this.stopProcessingActions) { try { @@ -190,7 +184,7 @@ export class TwitterPostClient { ); // Wait for the full interval before next processing await new Promise((resolve) => - setTimeout(resolve, actionInterval) + setTimeout(resolve, actionInterval * 60 * 1000) // now in minutes ); } } catch (error) { @@ -204,16 +198,7 @@ export class TwitterPostClient { } }; - if ( - this.runtime.getSetting("POST_IMMEDIATELY") != null && - this.runtime.getSetting("POST_IMMEDIATELY") !== "" - ) { - // Retrieve setting, default to false if not set or if the value is not "true" - postImmediately = - this.runtime.getSetting("POST_IMMEDIATELY") === "true" || false; - } - - if (postImmediately) { + if (this.client.twitterConfig.POST_IMMEDIATELY) { await this.generateNewTweet(); } @@ -225,12 +210,7 @@ export class TwitterPostClient { elizaLogger.log("Tweet generation loop disabled (dry run mode)"); } - // Add check for ENABLE_ACTION_PROCESSING before starting the loop - const enableActionProcessing = parseBooleanFromText( - this.runtime.getSetting("ENABLE_ACTION_PROCESSING") ?? "false" - ); - - if (enableActionProcessing && !this.isDryRun) { + if (this.client.twitterConfig.ENABLE_ACTION_PROCESSING && !this.isDryRun) { processActionsLoop().catch((error) => { elizaLogger.error( "Fatal error in process actions loop:", @@ -333,8 +313,7 @@ export class TwitterPostClient { // 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 + this.client.twitterConfig.MAX_TWEET_LENGTH ); return await this.sendStandardTweet( client, @@ -496,10 +475,7 @@ export class TwitterPostClient { } // 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 - ); + const maxTweetLength = this.client.twitterConfig.MAX_TWEET_LENGTH if (maxTweetLength) { cleanedContent = truncateToCompleteSentence( cleanedContent, diff --git a/packages/client-twitter/src/search.ts b/packages/client-twitter/src/search.ts index 8934abf72e3..37254ed1a7f 100644 --- a/packages/client-twitter/src/search.ts +++ b/packages/client-twitter/src/search.ts @@ -51,7 +51,7 @@ export class TwitterSearchClient { constructor(client: ClientBase, runtime: IAgentRuntime) { this.client = client; this.runtime = runtime; - this.twitterUsername = runtime.getSetting("TWITTER_USERNAME"); + this.twitterUsername = this.client.twitterConfig.TWITTER_USERNAME; } async start() { diff --git a/packages/client-twitter/src/utils.ts b/packages/client-twitter/src/utils.ts index 0e7db5d9868..ac93ef93ca1 100644 --- a/packages/client-twitter/src/utils.ts +++ b/packages/client-twitter/src/utils.ts @@ -4,7 +4,6 @@ import { Content, Memory, UUID } from "@elizaos/core"; import { stringToUuid } from "@elizaos/core"; import { ClientBase } from "./base"; import { elizaLogger } from "@elizaos/core"; -import { DEFAULT_MAX_TWEET_LENGTH } from "./environment"; import { Media } from "@elizaos/core"; import fs from "fs"; import path from "path"; @@ -174,8 +173,7 @@ export async function sendTweet( ): Promise { const tweetChunks = splitTweetContent( content.text, - Number(client.runtime.getSetting("MAX_TWEET_LENGTH")) || - DEFAULT_MAX_TWEET_LENGTH + client.twitterConfig.MAX_TWEET_LENGTH ); const sentTweets: Tweet[] = []; let previousTweetId = inReplyTo;