@defikitdotnet/x-ai-combat
Version:
XCombatAI - Social Media Engagement Template for the Agent Framework
461 lines • 23.6 kB
JavaScript
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