UNPKG

@defikitdotnet/x-ai-combat

Version:

XCombatAI - Social Media Engagement Template for the Agent Framework

461 lines 23.6 kB
import { SearchMode } from "@defikitdotnet/agent-twitter-client"; import { PostResource } from "../models/Post"; import { XAIAdapter } from "../db/XAIAdapter"; import { TwitterService } from "../services/twitter-service"; import { generateText } from "@defikitdotnet/core"; import { buildSystemPrompt } from "../utils/ai-utils"; import { RedisService } from "../services/redis-service"; // Import RedisService import { log, warn, error } from '../utils/logger'; /** * BotResponder plugin * * Monitors posts mentioning the agent and replies to unlinked users * Uses a Redis queue to manage replies */ export class BotResponderPlugin { constructor(runtime, twitterConfig) { this.name = "bot-responder"; this.version = "1.0.0"; this.description = "Monitors posts and replies to unlinked users using Redis queue"; this.checkInterval = null; this.checkIntervalMinutes = 15; this.isChecking = false; this.isProcessingQueue = false; this.lastCheckTime = null; this.adapter = new XAIAdapter(runtime.databaseAdapter); this.runtime = runtime; this.twitterConfig = twitterConfig; this.twitterService = new TwitterService(runtime, twitterConfig); this.agentId = runtime.agentId; this.redisService = new RedisService(this.runtime.agentId); } /** * Initialize the plugin */ async initialize() { log(`Initializing BotResponder Plugin for agent ${this.agentId}...`); try { // Check Redis connection via the service await this.redisService.ping(); log("Successfully connected to Redis service"); // Check for incomplete processing from previous run const lastProcessingId = await this.redisService.getLastProcessingTweetId(); if (lastProcessingId) { warn(`[${this.agentId}] BotResponder may have shut down uncleanly. Last processing tweet ID: ${lastProcessingId}. Consider verifying its status.`); // Optionally, clear it or handle recovery // await this.redisService.clearLastProcessingTweetId(); } // Load last check time from Redis via the service this.lastCheckTime = await this.redisService.getLastCheckTime(); if (this.lastCheckTime) { log(`Loaded last check time from Redis: ${this.lastCheckTime.toISOString()}`); } else { const keys = this.redisService.getKeys(); // Get keys for logging log(`No last check time found in Redis for key ${keys.lastCheckTimeKey}.`); } await this.twitterService.client.init(); log("Successfully connected to Twitter service"); await this.loadRepliedTweets(); // Still loads from DB to Redis Set initially this.startChecker(); } catch (err) { error("Failed to initialize BotResponder plugin:", err); // Attempt to close Redis connection via service on failure await this.redisService.quit(); } } /** * Load previously replied tweets from database into Redis Set via RedisService */ async loadRepliedTweets() { const keys = this.redisService.getKeys(); try { // Get replied interactions filtered by agentId const repliedInteractions = await this.adapter.getRepliedInteractions(this.agentId); if (repliedInteractions.length > 0) { const tweetIds = repliedInteractions .map(interaction => interaction.tweetId) .filter((id) => !!id); if (tweetIds.length > 0) { // Use RedisService method const addedCount = await this.redisService.addRepliedTweets(tweetIds); log(`Added ${addedCount} previously replied tweets to Redis set '${keys.repliedTweetsKey}' from DB.`); } } // Use RedisService method const currentSetSize = await this.redisService.getRepliedTweetCount(); log(`Redis set '${keys.repliedTweetsKey}' now contains ${currentSetSize} replied tweets.`); } catch (err) { error(`Error loading replied tweets into Redis set '${keys.repliedTweetsKey}':`, err); } } /** * Start the checker, running immediately only if necessary based on lastCheckTime. */ startChecker() { if (this.checkInterval) { clearInterval(this.checkInterval); } const now = Date.now(); const intervalMillis = this.checkIntervalMinutes * 60 * 1000; let runImmediate = true; if (this.lastCheckTime) { const timeSinceLastCheck = now - this.lastCheckTime.getTime(); if (timeSinceLastCheck < intervalMillis) { log(`Last check was at ${this.lastCheckTime.toISOString()} (${Math.round(timeSinceLastCheck / 1000)}s ago), less than interval (${this.checkIntervalMinutes} min). Skipping immediate check.`); runImmediate = false; } else { log(`Last check was at ${this.lastCheckTime.toISOString()} (${Math.round(timeSinceLastCheck / 1000)}s ago), more than interval (${this.checkIntervalMinutes} min). Running immediate check.`); } } else { log("No last check time recorded. Running immediate check."); } if (runImmediate) { this.checkForUnlinkedUsers().catch(err => error("Error in initial unlinked users check:", err)); } this.checkInterval = setInterval(() => { if (!this.isChecking) { this.checkForUnlinkedUsers().catch(err => error("Error in scheduled unlinked users check:", err)); } }, intervalMillis); log(`BotResponder for agent ${this.agentId} scheduled to run every ${this.checkIntervalMinutes} minutes.`); } stopChecker() { if (this.checkInterval) { clearInterval(this.checkInterval); this.checkInterval = null; log(`BotResponder for agent ${this.agentId} stopped`); } } setCheckInterval(minutes) { if (minutes < 5) { warn("Check interval too short, setting to minimum of 5 minutes"); minutes = 5; } this.checkIntervalMinutes = minutes; log(`Check interval updated to ${minutes} minutes for agent ${this.agentId}`); this.startChecker(); } async checkForUnlinkedUsers() { if (this.isChecking) { log("Check already in progress, skipping"); return; } this.isChecking = true; const checkStartTime = new Date(); const keys = this.redisService.getKeys(); // Get keys for logging try { log(`Starting unlinked users check for agent ${this.agentId}...`); const query = `@${this.twitterConfig.TWITTER_USERNAME}` || '@XCombatAI'; const tweetsGenerator = await this.twitterService.searchTweets(query, 100, SearchMode.Latest); let tweetCount = 0; for await (const tweet of tweetsGenerator) { tweetCount++; await this.processTweetForUnlinkedUser(tweet); } log(`Found ${tweetCount} tweets mentioning the agent ${this.agentId}`); // Trigger queue processing if not already running if (!this.isProcessingQueue) { this.processReplyQueue().catch(err => error(`Error starting reply queue processing for agent ${this.agentId}:`, err)); } else { log(`Queue is already being processed for agent ${this.agentId}, skipping additional processing`); } // Update lastCheckTime in memory and save to Redis via service this.lastCheckTime = checkStartTime; await this.redisService.setLastCheckTime(checkStartTime); log(`Unlinked users check completed at ${checkStartTime.toISOString()} for agent ${this.agentId}. Saved last check time to Redis key '${keys.lastCheckTimeKey}'.`); } catch (err) { error(`Error during unlinked users check for agent ${this.agentId}:`, err); try { const message = `⚠️ Error during BotResponder job for agent ${this.agentId}: ${err.message}`; await this.sendTelegramAlert(message); } catch (notifyError) { error("Failed to send Telegram notification:", notifyError); } } finally { this.isChecking = false; } } async processTweetForUnlinkedUser(tweet) { const keys = this.redisService.getKeys(); try { if (tweet.username && this.twitterConfig.TWITTER_USERNAME && tweet.username.toLowerCase() === this.twitterConfig.TWITTER_USERNAME.toLowerCase()) { return; } if (!tweet.id || !tweet.userId) { log("Tweet missing required properties", tweet); return; } // Use RedisService method const alreadyReplied = await this.redisService.isTweetReplied(tweet.id); if (alreadyReplied) { log(`Skipping tweet ${tweet.id} - already replied to (checked Redis set '${keys.repliedTweetsKey}')`); return; } const twitterId = tweet.userId; // Find user by twitterId and agentId const user = await this.adapter.findUserByTwitterId(twitterId, this.agentId); if (user) { let post = await this.adapter.findPostByTweetId(tweet.id); if (!post) { const newPost = PostResource.fromTweet(tweet); newPost.userId = user.id; newPost.agentId = this.agentId; // Set agentId for the new post await this.adapter.createPost(newPost); log(`Stored tweet ${tweet.id} from linked user ${user.username} for agent ${this.agentId}`); } await this.queueReply(tweet.id, twitterId); log(`User ${twitterId} is linked to agent ${this.agentId}, attempting to queue reply`); } else { log(`User ${twitterId} is not linked to agent ${this.agentId}, attempting to queue reply without storing tweet`); await this.queueReply(tweet.id, twitterId); } } catch (err) { error(`Error processing tweet ${tweet.id} for unlinked user ${tweet.userId} for agent ${this.agentId}:`, err); } } /** * Add a tweet to the Redis reply queue if not already replied. */ async queueReply(tweetId, userId) { const keys = this.redisService.getKeys(); try { // Use RedisService method const alreadyReplied = await this.redisService.isTweetReplied(tweetId); if (alreadyReplied) { log(`Skipping reply queue for tweet ${tweetId}, already replied (checked Redis set '${keys.repliedTweetsKey}')`); return; } // Use RedisService method const queuedReply = { tweetId, userId }; await this.redisService.addToReplyQueue(queuedReply); log(`Queued reply to user ${userId} for tweet ${tweetId} in Redis list '${keys.replyQueueKey}' for agent ${this.agentId}`); // Start processing the queue if not already running if (!this.isProcessingQueue) { this.processReplyQueue().catch(err => error(`Error starting reply queue processing after queue addition for agent ${this.agentId}:`, err)); } } catch (err) { error(`Error queuing reply for tweet ${tweetId}, user ${userId} in Redis (queue: '${keys.replyQueueKey}') for agent ${this.agentId}:`, err); } } /** * Process the reply queue with interval between replies */ async processReplyQueue() { if (this.isProcessingQueue) { log(`Queue processing already in progress for agent ${this.agentId}, skipping`); return; } this.isProcessingQueue = true; const keys = this.redisService.getKeys(); try { log(`Starting to process reply queue for agent ${this.agentId}`); // Get the reply interval from config with a minimum of 5 seconds const configIntervalSeconds = this.twitterConfig.XAICOMBAT_INTERVAL_REPLY || 5; const replyIntervalSeconds = Math.max(configIntervalSeconds, 5); // Ensure minimum of 5s log(`Using reply interval of ${replyIntervalSeconds} seconds between replies for agent ${this.agentId}`); while (true) { const queuedItems = await this.redisService.getReplyQueue(); if (queuedItems.length === 0) { log(`Redis reply queue '${keys.replyQueueKey}' is empty for agent ${this.agentId}, finishing processing`); break; } const nextItem = queuedItems[0]; log(`Processing next item from Redis queue '${keys.replyQueueKey}' for agent ${this.agentId}: Tweet ID ${nextItem.tweetId}`); // Set last processing ID before attempting await this.redisService.setLastProcessingTweetId(nextItem.tweetId); try { const alreadyReplied = await this.redisService.isTweetReplied(nextItem.tweetId); if (alreadyReplied) { log(`Skipping reply to tweet ${nextItem.tweetId}, already replied (final check in set '${keys.repliedTweetsKey}')`); } else { await this.sendReplyToUnlinkedUser(nextItem.tweetId, nextItem.userId); await this.redisService.addRepliedTweet(nextItem.tweetId); await this.adapter.markInteractionAsReplied(nextItem.tweetId, this.agentId); log(`Successfully processed reply to tweet ${nextItem.tweetId} for agent ${this.agentId}`); } // Remove from queue and clear last processing ID on success await this.redisService.removeFromReplyQueue(); await this.redisService.clearLastProcessingTweetId(); } catch (err) { error(`Error processing reply to tweet ${nextItem.tweetId} for agent ${this.agentId}:`, err); // Remove failed item, requeue it, and clear last processing ID await this.redisService.removeFromReplyQueue(); await this.redisService.addToReplyQueue(nextItem); await this.redisService.clearLastProcessingTweetId(); // Clear even on failure/requeue log(`Requeued failed reply to tweet ${nextItem.tweetId} for agent ${this.agentId}`); } // Wait for the specified interval before processing the next item log(`Waiting ${replyIntervalSeconds} seconds before processing next reply for agent ${this.agentId}`); await new Promise(resolve => setTimeout(resolve, replyIntervalSeconds * 1000)); } } catch (err) { error(`Error processing Redis reply queue '${keys.replyQueueKey}' for agent ${this.agentId}:`, err); // Ensure last processing ID is cleared if the loop crashes await this.redisService.clearLastProcessingTweetId().catch(clearErr => error(`Failed to clear last processing ID after main loop error for agent ${this.agentId}:`, clearErr)); } finally { this.isProcessingQueue = false; log(`Finished processing reply queue for agent ${this.agentId}`); } } async sendReplyToUnlinkedUser(tweetId, twitterUserId) { // This method remains largely unchanged as it deals with Adapter and TwitterService const linkedUser = await this.adapter.findUserByTwitterId(twitterUserId, this.agentId); // Pass agentId let tweetContent = ""; if (linkedUser) { const post = await this.adapter.findPostByTweetId(tweetId); if (post) { tweetContent = post.content; } else { try { const tweet = await this.twitterService.client.getTweet(tweetId); if (tweet) { const tweetData = tweet; tweetContent = tweetData.text || tweetData.content || ""; } } catch (apiError) { error(`Error fetching tweet content from API for tweet ${tweetId}:`, apiError); } } } const content = await this.generateReplyContent(twitterUserId, tweetContent, linkedUser !== null); const replyTweet = await this.twitterService.replyToTweet(tweetId, content); if (replyTweet) { log(`Successfully replied to user ${twitterUserId} on tweet ${tweetId} for agent ${this.agentId}`); } else { error(`Failed to reply to user ${twitterUserId} on tweet ${tweetId} for agent ${this.agentId}`); throw new Error(`Failed to send Twitter reply for tweet ${tweetId}`); } } async generateReplyContent(twitterUserId, tweetContent, isLinked) { const appUrl = process.env.APP_URL || 'https://xcombat.ai'; const invitationTemplates = [ `Thanks for reaching out! 🚀 To get personalized responses and track your fame points, please link your Twitter account at ${appUrl}.`, // ... other templates `XCombatAI works best with linked accounts! 🔗 Connect yours at ${appUrl} to unlock all features.` ]; if (!isLinked) { const randomIndex = Math.floor(Math.random() * invitationTemplates.length); return invitationTemplates[randomIndex]; } else { try { const linkedUser = await this.adapter.findUserByTwitterId(twitterUserId, this.agentId); // Pass agentId if (!linkedUser) { warn(`Could not find linked user ${twitterUserId} for agent ${this.agentId} in DB for reply generation, using fallback.`); const randomIndex = Math.floor(Math.random() * invitationTemplates.length); return invitationTemplates[randomIndex]; // Fallback if user somehow not found } const { posts } = await this.adapter.getPostsByUserId(linkedUser.id, 1, 100, this.agentId); // Pass agentId const totalStats = { likes: 0, retweets: 0, quotes: 0, replies: 0, famePoints: 0 }; posts.forEach((post) => { if (post.stats) { totalStats.likes += post.stats.likes || 0; totalStats.retweets += post.stats.retweets || 0; totalStats.quotes += post.stats.quotes || 0; totalStats.replies += post.stats.replies || 0; totalStats.famePoints += post.stats.famePoints || 0; } }); // Prepare context for prompt building const promptContext = { username: linkedUser.username, userId: linkedUser.id, tweetContent: tweetContent, postCount: posts.length, stats: totalStats, // Pass aggregated stats famePoints: Math.round(totalStats.famePoints * 10) / 10, isLinked: true, agentId: this.agentId // Add agentId to the prompt context }; // Ensure runtime.character exists before using it if (!this.runtime.character) { throw new Error("Agent character definition is missing in runtime."); } // Build the system prompt using the utility function const systemPrompt = buildSystemPrompt(promptContext, this.runtime.character); // Generate reply using AI service const reply = await generateText({ runtime: this.runtime, context: systemPrompt, // Pass the generated system prompt modelClass: 'default' // Or determine dynamically if needed }); return reply; } catch (err) { error(`Error generating reply content for linked user ${twitterUserId} for agent ${this.agentId}:`, err); // Provide a slightly more informative fallback using utility function const userName = (await this.adapter.findUserByTwitterId(twitterUserId, this.agentId))?.username; // Pass agentId // Assuming getFallbackReply exists and is imported from ai-utils // return getFallbackReply({ userId: twitterUserId, username: userName, isLinked: true, postCount: 0, stats: {likes:0, retweets:0, quotes:0, replies:0}, famePoints: 0 }); // Simple fallback if getFallbackReply isn't set up yet return `Thanks for your message, ${userName || 'friend'}! Having trouble generating a specific response right now, but keep engaging to climb the leaderboard! Check your rank at ${appUrl}`; } } } async sendTelegramAlert(message) { const telegramBotToken = process.env.TELEGRAM_BOT_TOKEN; const telegramChatId = process.env.TELEGRAM_CHAT_ID; if (!telegramBotToken || !telegramChatId) { warn("Telegram notifications not configured, skipping alert"); return; } try { const telegramUrl = `https://api.telegram.org/bot${telegramBotToken}/sendMessage`; const response = await fetch(telegramUrl, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ chat_id: telegramChatId, text: message, parse_mode: 'HTML', }), }); if (!response.ok) { const errorBody = await response.text(); throw new Error(`Telegram API response: ${response.status} ${response.statusText} - ${errorBody}`); } log("Telegram notification sent successfully"); } catch (err) { error("Error sending Telegram notification:", err); } } getLastCheckTime() { return this.lastCheckTime; } async runManualCheck() { if (this.isChecking) { log(`A check is already in progress for agent ${this.agentId}`); return; } log(`Starting manual unlinked users check for agent ${this.agentId}...`); await this.checkForUnlinkedUsers(); } async shutdown() { log(`Shutting down BotResponder Plugin for agent ${this.agentId}...`); this.stopChecker(); // Use RedisService method await this.redisService.quit(); } } //# sourceMappingURL=bot-responder-plugin.js.map