feat(bots): Consolidate bot posting into autonomous module and improve caching
- Remove scheduled posting logic and consolidate all bot posting into autonomous module - Simplify cron endpoint to only call processAllAutonomousBots with cleaner response format - Add background post caching for swarm follow operations via cacheSwarmUserPosts - Update background scheduler to remove scheduled posting references and streamline logging - Improve autonomous module documentation to clarify schedule-based posting with optional sources - Enhance error tracking and reporting across bot task execution - Reduce code duplication by eliminating separate scheduled/autonomous processing paths
This commit is contained in:
@@ -1,11 +1,10 @@
|
|||||||
/**
|
/**
|
||||||
* Cron endpoint for bot scheduled posting
|
* Cron endpoint for bot autonomous posting
|
||||||
*
|
*
|
||||||
* Call this endpoint periodically (e.g., every minute) via cron job or PM2
|
* Call this endpoint periodically (e.g., every minute) via cron job or PM2
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { processScheduledPosts } from '@/lib/bots/scheduler';
|
|
||||||
import { processAllAutonomousBots } from '@/lib/bots/autonomous';
|
import { processAllAutonomousBots } from '@/lib/bots/autonomous';
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
@@ -18,23 +17,18 @@ export async function POST(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Process scheduled posts
|
const results = await processAllAutonomousBots();
|
||||||
const scheduledResult = await processScheduledPosts();
|
|
||||||
|
|
||||||
// Process autonomous bots
|
|
||||||
const autonomousResult = await processAllAutonomousBots();
|
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
scheduled: {
|
total: results.length,
|
||||||
processed: scheduledResult.processed,
|
posted: results.filter(r => r.result.posted).length,
|
||||||
skipped: scheduledResult.skipped,
|
errors: results.filter(r => r.error).length,
|
||||||
errors: scheduledResult.errors.length,
|
details: results.map(r => ({
|
||||||
},
|
handle: r.botHandle,
|
||||||
autonomous: {
|
posted: r.result.posted,
|
||||||
total: autonomousResult.length,
|
reason: r.result.reason || r.error,
|
||||||
posted: autonomousResult.filter(r => r.result.posted).length,
|
})),
|
||||||
},
|
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { resolveRemoteUser } from '@/lib/activitypub/fetch';
|
|||||||
import { createFollowActivity, createUndoActivity } from '@/lib/activitypub/activities';
|
import { createFollowActivity, createUndoActivity } from '@/lib/activitypub/activities';
|
||||||
import { deliverActivity } from '@/lib/activitypub/outbox';
|
import { deliverActivity } from '@/lib/activitypub/outbox';
|
||||||
import { cacheRemoteUserPosts } from '@/lib/activitypub/cache';
|
import { cacheRemoteUserPosts } from '@/lib/activitypub/cache';
|
||||||
import { isSwarmNode, deliverSwarmFollow, deliverSwarmUnfollow } from '@/lib/swarm/interactions';
|
import { isSwarmNode, deliverSwarmFollow, deliverSwarmUnfollow, cacheSwarmUserPosts } from '@/lib/swarm/interactions';
|
||||||
|
|
||||||
type RouteContext = { params: Promise<{ handle: string }> };
|
type RouteContext = { params: Promise<{ handle: string }> };
|
||||||
|
|
||||||
@@ -156,6 +156,11 @@ export async function POST(request: Request, context: RouteContext) {
|
|||||||
.set({ followingCount: currentUser.followingCount + 1 })
|
.set({ followingCount: currentUser.followingCount + 1 })
|
||||||
.where(eq(users.id, currentUser.id));
|
.where(eq(users.id, currentUser.id));
|
||||||
|
|
||||||
|
// Cache the remote user's recent posts in the background
|
||||||
|
cacheSwarmUserPosts(remote.handle, remote.domain, targetHandle, 20)
|
||||||
|
.then(result => console.log(`[Swarm] Cached ${result.cached} posts for ${targetHandle}`))
|
||||||
|
.catch(err => console.error('[Swarm] Error caching remote posts:', err));
|
||||||
|
|
||||||
console.log(`[Swarm] Follow delivered to ${remote.domain} for @${remote.handle}`);
|
console.log(`[Swarm] Follow delivered to ${remote.domain} for @${remote.handle}`);
|
||||||
return NextResponse.json({ success: true, following: true, remote: true, swarm: true });
|
return NextResponse.json({ success: true, following: true, remote: true, swarm: true });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,12 +2,11 @@
|
|||||||
* Background Task Scheduler
|
* Background Task Scheduler
|
||||||
*
|
*
|
||||||
* Runs periodic tasks within the Next.js process:
|
* Runs periodic tasks within the Next.js process:
|
||||||
* - Bot scheduling (every 1 minute)
|
* - Bot autonomous posting (every 1 minute)
|
||||||
* - Swarm gossip (every 5 minutes)
|
* - Swarm gossip (every 5 minutes)
|
||||||
* - Swarm announcement (on startup)
|
* - Swarm announcement (on startup)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { processScheduledPosts } from '@/lib/bots/scheduler';
|
|
||||||
import { processAllAutonomousBots } from '@/lib/bots/autonomous';
|
import { processAllAutonomousBots } from '@/lib/bots/autonomous';
|
||||||
import { runGossipRound } from '@/lib/swarm/gossip';
|
import { runGossipRound } from '@/lib/swarm/gossip';
|
||||||
import { announceToSeeds } from '@/lib/swarm/discovery';
|
import { announceToSeeds } from '@/lib/swarm/discovery';
|
||||||
@@ -30,39 +29,30 @@ function log(category: string, message: string, data?: unknown) {
|
|||||||
|
|
||||||
async function runBotTasks() {
|
async function runBotTasks() {
|
||||||
try {
|
try {
|
||||||
const scheduledResult = await processScheduledPosts();
|
const results = await processAllAutonomousBots();
|
||||||
const autonomousResult = await processAllAutonomousBots();
|
|
||||||
|
|
||||||
const posted = autonomousResult.filter(r => r.result.posted).length;
|
const posted = results.filter(r => r.result.posted).length;
|
||||||
const skipped = scheduledResult.skipped;
|
const errors = results.filter(r => r.error).length;
|
||||||
const errors = scheduledResult.errors.length + autonomousResult.filter(r => r.error).length;
|
|
||||||
|
|
||||||
// Always log bot task results for debugging
|
if (posted > 0) {
|
||||||
if (scheduledResult.processed > 0 || posted > 0) {
|
log('BOTS', `Created ${posted} posts`);
|
||||||
log('BOTS', `Processed ${scheduledResult.processed} scheduled, ${posted} autonomous posts`);
|
} else if (results.length > 0) {
|
||||||
} else if (scheduledResult.details.length > 0 || autonomousResult.length > 0) {
|
|
||||||
// Log why bots didn't post
|
// Log why bots didn't post
|
||||||
const reasons = scheduledResult.details
|
const reasons = results
|
||||||
.filter(d => d.status !== 'posted')
|
|
||||||
.map(d => `${d.botId.slice(0, 8)}: ${d.status}${d.message ? ` (${d.message})` : ''}`)
|
|
||||||
.slice(0, 3);
|
|
||||||
|
|
||||||
const autoReasons = autonomousResult
|
|
||||||
.filter(r => !r.result.posted)
|
.filter(r => !r.result.posted)
|
||||||
.map(r => `${r.botHandle}: ${r.result.reason || r.error || 'unknown'}`)
|
.map(r => `${r.botHandle}: ${r.result.reason || r.error || 'unknown'}`)
|
||||||
.slice(0, 3);
|
.slice(0, 5);
|
||||||
|
|
||||||
if (reasons.length > 0 || autoReasons.length > 0) {
|
if (reasons.length > 0) {
|
||||||
log('BOTS', `No posts created. Scheduled: ${scheduledResult.details.length} checked, ${skipped} skipped. Autonomous: ${autonomousResult.length} checked.`);
|
log('BOTS', `${results.length} bots checked, no posts. Reasons: ${reasons.join('; ')}`);
|
||||||
if (reasons.length > 0) log('BOTS', `Scheduled skip reasons: ${reasons.join('; ')}`);
|
|
||||||
if (autoReasons.length > 0) log('BOTS', `Autonomous skip reasons: ${autoReasons.join('; ')}`);
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
log('BOTS', 'No active bots found');
|
log('BOTS', 'No active bots found');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (errors > 0) {
|
if (errors > 0) {
|
||||||
log('BOTS', `Errors: ${scheduledResult.errors.join('; ')}`);
|
const errorMsgs = results.filter(r => r.error).map(r => `${r.botHandle}: ${r.error}`);
|
||||||
|
log('BOTS', `Errors: ${errorMsgs.join('; ')}`);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
log('BOTS', `Error: ${error}`);
|
log('BOTS', `Error: ${error}`);
|
||||||
|
|||||||
+166
-108
@@ -2,8 +2,8 @@
|
|||||||
* Autonomous Posting Module
|
* Autonomous Posting Module
|
||||||
*
|
*
|
||||||
* Implements autonomous posting logic for bots. When autonomous mode is enabled,
|
* Implements autonomous posting logic for bots. When autonomous mode is enabled,
|
||||||
* bots evaluate content from their sources and decide whether to post based on
|
* bots post based on their schedule configuration. Content sources are optional -
|
||||||
* content interest. All posting respects rate limits.
|
* bots without sources generate original posts based on their personality.
|
||||||
*
|
*
|
||||||
* Requirements: 6.1, 6.2, 6.3, 6.5
|
* Requirements: 6.1, 6.2, 6.3, 6.5
|
||||||
*/
|
*/
|
||||||
@@ -12,8 +12,8 @@ import { db, botContentItems, bots } from '@/db';
|
|||||||
import { eq, and } from 'drizzle-orm';
|
import { eq, and } from 'drizzle-orm';
|
||||||
import { ContentGenerator, type Bot as ContentGeneratorBot, type ContentItem } from './contentGenerator';
|
import { ContentGenerator, type Bot as ContentGeneratorBot, type ContentItem } from './contentGenerator';
|
||||||
import { canPost, recordPost } from './rateLimiter';
|
import { canPost, recordPost } from './rateLimiter';
|
||||||
import { getBotById } from './botManager';
|
|
||||||
import { decryptApiKey, deserializeEncryptedData } from './encryption';
|
import { decryptApiKey, deserializeEncryptedData } from './encryption';
|
||||||
|
import { parseScheduleConfig, isDue } from './scheduler';
|
||||||
|
|
||||||
// ============================================
|
// ============================================
|
||||||
// TYPES
|
// TYPES
|
||||||
@@ -105,6 +105,25 @@ export const MIN_INTEREST_SCORE = 60;
|
|||||||
// HELPER FUNCTIONS
|
// HELPER FUNCTIONS
|
||||||
// ============================================
|
// ============================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a bot has any active content sources configured.
|
||||||
|
*
|
||||||
|
* @param botId - The ID of the bot
|
||||||
|
* @returns True if bot has content sources
|
||||||
|
*/
|
||||||
|
async function hasContentSources(botId: string): Promise<boolean> {
|
||||||
|
const bot = await db.query.bots.findFirst({
|
||||||
|
where: eq(bots.id, botId),
|
||||||
|
with: {
|
||||||
|
contentSources: {
|
||||||
|
where: (sources, { eq }) => eq(sources.isActive, true),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return !!(bot?.contentSources && bot.contentSources.length > 0);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get unprocessed content items for a bot.
|
* Get unprocessed content items for a bot.
|
||||||
* Returns content items that haven't been processed yet, ordered by published date.
|
* Returns content items that haven't been processed yet, ordered by published date.
|
||||||
@@ -261,25 +280,7 @@ export async function evaluateContentForPosting(
|
|||||||
botId: string,
|
botId: string,
|
||||||
contentItem: ContentItem
|
contentItem: ContentItem
|
||||||
): Promise<AutonomousPostEvaluation> {
|
): Promise<AutonomousPostEvaluation> {
|
||||||
// Get bot
|
// Get bot with user relation
|
||||||
const bot = await getBotById(botId);
|
|
||||||
if (!bot) {
|
|
||||||
throw new AutonomousPostError(
|
|
||||||
`Bot not found: ${botId}`,
|
|
||||||
'BOT_NOT_FOUND'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if autonomous mode is enabled
|
|
||||||
if (!bot.autonomousMode) {
|
|
||||||
return {
|
|
||||||
shouldPost: false,
|
|
||||||
reason: 'Autonomous mode is disabled for this bot',
|
|
||||||
contentItem,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get bot with encrypted API key
|
|
||||||
const dbBot = await db.query.bots.findFirst({
|
const dbBot = await db.query.bots.findFirst({
|
||||||
where: eq(bots.id, botId),
|
where: eq(bots.id, botId),
|
||||||
with: { user: true },
|
with: { user: true },
|
||||||
@@ -292,6 +293,15 @@ export async function evaluateContentForPosting(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check if autonomous mode is enabled
|
||||||
|
if (!dbBot.autonomousMode) {
|
||||||
|
return {
|
||||||
|
shouldPost: false,
|
||||||
|
reason: 'Autonomous mode is disabled for this bot',
|
||||||
|
contentItem,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// Check if bot has API key
|
// Check if bot has API key
|
||||||
try {
|
try {
|
||||||
const apiKey = getDecryptedApiKeyForBot(dbBot);
|
const apiKey = getDecryptedApiKeyForBot(dbBot);
|
||||||
@@ -340,7 +350,7 @@ export async function evaluateContentForPosting(
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Attempt to create an autonomous post for a bot.
|
* Attempt to create an autonomous post for a bot.
|
||||||
* Evaluates unprocessed content and posts if interesting and rate limits allow.
|
* Checks schedule timing, then either uses content sources or generates original posts.
|
||||||
*
|
*
|
||||||
* This is the main entry point for autonomous posting.
|
* This is the main entry point for autonomous posting.
|
||||||
*
|
*
|
||||||
@@ -359,9 +369,13 @@ export async function attemptAutonomousPost(
|
|||||||
skipRateLimitCheck = false,
|
skipRateLimitCheck = false,
|
||||||
} = options;
|
} = options;
|
||||||
|
|
||||||
// Get bot
|
// Get bot with schedule config
|
||||||
const bot = await getBotById(botId);
|
const dbBot = await db.query.bots.findFirst({
|
||||||
if (!bot) {
|
where: eq(bots.id, botId),
|
||||||
|
with: { user: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!dbBot) {
|
||||||
throw new AutonomousPostError(
|
throw new AutonomousPostError(
|
||||||
`Bot not found: ${botId}`,
|
`Bot not found: ${botId}`,
|
||||||
'BOT_NOT_FOUND'
|
'BOT_NOT_FOUND'
|
||||||
@@ -369,13 +383,40 @@ export async function attemptAutonomousPost(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check if autonomous mode is enabled (Requirement 6.5)
|
// Check if autonomous mode is enabled (Requirement 6.5)
|
||||||
if (!bot.autonomousMode) {
|
if (!dbBot.autonomousMode) {
|
||||||
return {
|
return {
|
||||||
posted: false,
|
posted: false,
|
||||||
reason: 'Autonomous mode is disabled for this bot',
|
reason: 'Autonomous mode is disabled',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check if bot is active and not suspended
|
||||||
|
if (!dbBot.isActive) {
|
||||||
|
return {
|
||||||
|
posted: false,
|
||||||
|
reason: 'Bot is not active',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dbBot.isSuspended) {
|
||||||
|
return {
|
||||||
|
posted: false,
|
||||||
|
reason: 'Bot is suspended',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check schedule timing (if configured)
|
||||||
|
const scheduleConfig = parseScheduleConfig(dbBot.scheduleConfig);
|
||||||
|
if (scheduleConfig) {
|
||||||
|
const dueResult = isDue(scheduleConfig, dbBot.lastPostAt);
|
||||||
|
if (!dueResult.isDue) {
|
||||||
|
return {
|
||||||
|
posted: false,
|
||||||
|
reason: dueResult.reason || 'Not scheduled to post yet',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Check rate limits (Requirement 6.3)
|
// Check rate limits (Requirement 6.3)
|
||||||
if (!skipRateLimitCheck) {
|
if (!skipRateLimitCheck) {
|
||||||
const rateLimitCheck = await canPost(botId);
|
const rateLimitCheck = await canPost(botId);
|
||||||
@@ -387,95 +428,105 @@ export async function attemptAutonomousPost(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get unprocessed content items (Requirement 6.1)
|
// Check if bot has API key
|
||||||
const contentItems = await getUnprocessedContentItems(botId, maxEvaluations);
|
try {
|
||||||
|
const apiKey = getDecryptedApiKeyForBot(dbBot);
|
||||||
if (contentItems.length === 0) {
|
if (!apiKey || apiKey === '__REMOVED__') {
|
||||||
return {
|
throw new AutonomousPostError(
|
||||||
posted: false,
|
'Bot does not have a valid API key configured',
|
||||||
reason: 'No unprocessed content available',
|
'NO_API_KEY'
|
||||||
};
|
);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof AutonomousPostError) throw error;
|
||||||
|
throw new AutonomousPostError(
|
||||||
|
'Failed to decrypt bot API key',
|
||||||
|
'NO_API_KEY',
|
||||||
|
error instanceof Error ? error : undefined
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Evaluate content items until we find one worth posting (Requirement 6.2)
|
const contentGeneratorBot = toContentGeneratorBot(dbBot, dbBot.user.handle);
|
||||||
for (const contentItem of contentItems) {
|
const generator = new ContentGenerator(contentGeneratorBot);
|
||||||
try {
|
|
||||||
const evaluation = await evaluateContentForPosting(botId, contentItem);
|
|
||||||
|
|
||||||
// Mark as processed regardless of decision
|
// Check if bot has content sources
|
||||||
await markContentItemProcessed(
|
const hasSourcesConfigured = await hasContentSources(botId);
|
||||||
contentItem.id,
|
|
||||||
undefined,
|
|
||||||
evaluation.interestScore,
|
|
||||||
evaluation.reason
|
|
||||||
);
|
|
||||||
|
|
||||||
if (evaluation.shouldPost) {
|
if (hasSourcesConfigured) {
|
||||||
// Generate and create the post (Requirement 6.3)
|
// Bot has content sources - try content-based posting first
|
||||||
|
const contentItems = await getUnprocessedContentItems(botId, maxEvaluations);
|
||||||
|
|
||||||
|
if (contentItems.length > 0) {
|
||||||
|
// Evaluate content items until we find one worth posting
|
||||||
|
for (const contentItem of contentItems) {
|
||||||
try {
|
try {
|
||||||
const dbBot = await db.query.bots.findFirst({
|
const evaluation = await evaluateContentForPosting(botId, contentItem);
|
||||||
where: eq(bots.id, botId),
|
|
||||||
with: { user: true },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!dbBot) {
|
// Mark as processed regardless of decision
|
||||||
throw new Error('Bot not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
const contentGeneratorBot = toContentGeneratorBot(dbBot, dbBot.user.handle);
|
|
||||||
const generator = new ContentGenerator(contentGeneratorBot);
|
|
||||||
|
|
||||||
const generatedContent = await generator.generatePost(contentItem);
|
|
||||||
|
|
||||||
// Record the post for rate limiting
|
|
||||||
if (!skipRateLimitCheck) {
|
|
||||||
await recordPost(botId);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update the content item with the post ID
|
|
||||||
// Note: Actual post creation would happen in the posting module
|
|
||||||
// For now, we just return the generated content
|
|
||||||
await markContentItemProcessed(
|
await markContentItemProcessed(
|
||||||
contentItem.id,
|
contentItem.id,
|
||||||
'pending', // Placeholder - actual post ID would be set by posting module
|
undefined,
|
||||||
evaluation.interestScore,
|
evaluation.interestScore,
|
||||||
evaluation.reason
|
evaluation.reason
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
if (evaluation.shouldPost) {
|
||||||
posted: true,
|
const generatedContent = await generator.generatePost(contentItem);
|
||||||
postText: generatedContent.text,
|
|
||||||
contentItem,
|
// Record the post for rate limiting
|
||||||
};
|
if (!skipRateLimitCheck) {
|
||||||
|
await recordPost(botId);
|
||||||
|
}
|
||||||
|
|
||||||
|
await markContentItemProcessed(
|
||||||
|
contentItem.id,
|
||||||
|
'pending',
|
||||||
|
evaluation.interestScore,
|
||||||
|
evaluation.reason
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
posted: true,
|
||||||
|
postText: generatedContent.text,
|
||||||
|
contentItem,
|
||||||
|
};
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw new AutonomousPostError(
|
console.error(`Error evaluating content item ${contentItem.id}:`, error);
|
||||||
`Failed to create post: ${error instanceof Error ? error.message : String(error)}`,
|
await markContentItemProcessed(
|
||||||
'POST_CREATION_FAILED',
|
contentItem.id,
|
||||||
error instanceof Error ? error : undefined
|
undefined,
|
||||||
|
0,
|
||||||
|
`Evaluation failed: ${error instanceof Error ? error.message : String(error)}`
|
||||||
);
|
);
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
// No interesting content found - fall through to generate original post
|
||||||
// Log error but continue to next content item
|
|
||||||
console.error(`Error evaluating content item ${contentItem.id}:`, error);
|
|
||||||
|
|
||||||
// Mark as processed with error
|
|
||||||
await markContentItemProcessed(
|
|
||||||
contentItem.id,
|
|
||||||
undefined,
|
|
||||||
0,
|
|
||||||
`Evaluation failed: ${error instanceof Error ? error.message : String(error)}`
|
|
||||||
);
|
|
||||||
|
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// No interesting content found
|
// Generate original post based on personality (no content source needed)
|
||||||
return {
|
try {
|
||||||
posted: false,
|
const generatedContent = await generator.generatePost(undefined, undefined);
|
||||||
reason: 'No interesting content found after evaluating available items',
|
|
||||||
};
|
// Record the post for rate limiting
|
||||||
|
if (!skipRateLimitCheck) {
|
||||||
|
await recordPost(botId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
posted: true,
|
||||||
|
postText: generatedContent.text,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof AutonomousPostError) throw error;
|
||||||
|
throw new AutonomousPostError(
|
||||||
|
`Failed to create post: ${error instanceof Error ? error.message : String(error)}`,
|
||||||
|
'POST_CREATION_FAILED',
|
||||||
|
error instanceof Error ? error : undefined
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -542,7 +593,10 @@ export async function canPostAutonomously(botId: string): Promise<{
|
|||||||
reason?: string;
|
reason?: string;
|
||||||
}> {
|
}> {
|
||||||
// Get bot
|
// Get bot
|
||||||
const bot = await getBotById(botId);
|
const bot = await db.query.bots.findFirst({
|
||||||
|
where: eq(bots.id, botId),
|
||||||
|
});
|
||||||
|
|
||||||
if (!bot) {
|
if (!bot) {
|
||||||
return {
|
return {
|
||||||
canPost: false,
|
canPost: false,
|
||||||
@@ -573,6 +627,18 @@ export async function canPostAutonomously(botId: string): Promise<{
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check schedule timing (if configured)
|
||||||
|
const scheduleConfig = parseScheduleConfig(bot.scheduleConfig);
|
||||||
|
if (scheduleConfig) {
|
||||||
|
const dueResult = isDue(scheduleConfig, bot.lastPostAt);
|
||||||
|
if (!dueResult.isDue) {
|
||||||
|
return {
|
||||||
|
canPost: false,
|
||||||
|
reason: dueResult.reason || 'Not scheduled to post yet',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Check rate limits (Requirement 6.3)
|
// Check rate limits (Requirement 6.3)
|
||||||
const rateLimitCheck = await canPost(botId);
|
const rateLimitCheck = await canPost(botId);
|
||||||
if (!rateLimitCheck.allowed) {
|
if (!rateLimitCheck.allowed) {
|
||||||
@@ -582,15 +648,7 @@ export async function canPostAutonomously(botId: string): Promise<{
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if there's content to evaluate (Requirement 6.1)
|
// Bots can always post - content sources are optional
|
||||||
const contentItems = await getUnprocessedContentItems(botId, 1);
|
|
||||||
if (contentItems.length === 0) {
|
|
||||||
return {
|
|
||||||
canPost: false,
|
|
||||||
reason: 'No unprocessed content available',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
canPost: true,
|
canPost: true,
|
||||||
};
|
};
|
||||||
|
|||||||
+89
-45
@@ -773,6 +773,7 @@ export async function getNextUnprocessedContent(botId: string): Promise<{
|
|||||||
/**
|
/**
|
||||||
* Process scheduled posts for all active bots.
|
* Process scheduled posts for all active bots.
|
||||||
* Checks each bot's schedule, rate limits, and content availability.
|
* Checks each bot's schedule, rate limits, and content availability.
|
||||||
|
* Bots without content sources can still post based on their personality.
|
||||||
*
|
*
|
||||||
* @returns Processing result with statistics
|
* @returns Processing result with statistics
|
||||||
*
|
*
|
||||||
@@ -835,54 +836,79 @@ export async function processScheduledPosts(): Promise<ProcessScheduledPostsResu
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch fresh content from sources before checking availability
|
// Check if bot has content sources
|
||||||
await fetchAllSourcesForBot(bot.id, { maxItems: 20, timeout: 15000 });
|
const hasContentSources = await hasUnprocessedContent(bot.id).catch(() => false);
|
||||||
|
|
||||||
// Check for unprocessed content (Requirement 5.5)
|
// If bot has content sources, fetch fresh content and check availability
|
||||||
const hasContent = await hasUnprocessedContent(bot.id);
|
if (hasContentSources || await botHasActiveSources(bot.id)) {
|
||||||
|
// Fetch fresh content from sources before checking availability
|
||||||
if (!hasContent) {
|
await fetchAllSourcesForBot(bot.id, { maxItems: 20, timeout: 15000 });
|
||||||
result.details.push({
|
|
||||||
botId: bot.id,
|
// Check for unprocessed content (Requirement 5.5)
|
||||||
status: 'skipped_no_content',
|
const hasContent = await hasUnprocessedContent(bot.id);
|
||||||
message: 'No unprocessed content available',
|
|
||||||
|
if (!hasContent) {
|
||||||
|
result.details.push({
|
||||||
|
botId: bot.id,
|
||||||
|
status: 'skipped_no_content',
|
||||||
|
message: 'No unprocessed content available',
|
||||||
|
});
|
||||||
|
result.skipped++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the next content item
|
||||||
|
const contentItem = await getNextUnprocessedContent(bot.id);
|
||||||
|
|
||||||
|
if (!contentItem) {
|
||||||
|
result.details.push({
|
||||||
|
botId: bot.id,
|
||||||
|
status: 'skipped_no_content',
|
||||||
|
message: 'Failed to retrieve content item',
|
||||||
|
});
|
||||||
|
result.skipped++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trigger post creation with the content item
|
||||||
|
const postResult = await triggerPost(bot.id, {
|
||||||
|
sourceContentId: contentItem.id,
|
||||||
});
|
});
|
||||||
result.skipped++;
|
|
||||||
continue;
|
if (postResult.success) {
|
||||||
}
|
result.details.push({
|
||||||
|
botId: bot.id,
|
||||||
// Get the next content item
|
status: 'posted',
|
||||||
const contentItem = await getNextUnprocessedContent(bot.id);
|
message: `Posted: ${contentItem.title.substring(0, 50)}...`,
|
||||||
|
});
|
||||||
if (!contentItem) {
|
result.processed++;
|
||||||
result.details.push({
|
} else {
|
||||||
botId: bot.id,
|
result.details.push({
|
||||||
status: 'skipped_no_content',
|
botId: bot.id,
|
||||||
message: 'Failed to retrieve content item',
|
status: 'error',
|
||||||
});
|
message: postResult.error || 'Failed to create post',
|
||||||
result.skipped++;
|
});
|
||||||
continue;
|
result.errors.push(`Bot ${bot.id}: ${postResult.error}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Trigger post creation with the content item
|
|
||||||
const postResult = await triggerPost(bot.id, {
|
|
||||||
sourceContentId: contentItem.id,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (postResult.success) {
|
|
||||||
result.details.push({
|
|
||||||
botId: bot.id,
|
|
||||||
status: 'posted',
|
|
||||||
message: `Posted: ${contentItem.title.substring(0, 50)}...`,
|
|
||||||
});
|
|
||||||
result.processed++;
|
|
||||||
} else {
|
} else {
|
||||||
result.details.push({
|
// Bot has no content sources - generate original post based on personality
|
||||||
botId: bot.id,
|
const postResult = await triggerPost(bot.id, {});
|
||||||
status: 'error',
|
|
||||||
message: postResult.error || 'Failed to create post',
|
if (postResult.success) {
|
||||||
});
|
result.details.push({
|
||||||
result.errors.push(`Bot ${bot.id}: ${postResult.error}`);
|
botId: bot.id,
|
||||||
|
status: 'posted',
|
||||||
|
message: 'Posted original content',
|
||||||
|
});
|
||||||
|
result.processed++;
|
||||||
|
} else {
|
||||||
|
result.details.push({
|
||||||
|
botId: bot.id,
|
||||||
|
status: 'error',
|
||||||
|
message: postResult.error || 'Failed to create post',
|
||||||
|
});
|
||||||
|
result.errors.push(`Bot ${bot.id}: ${postResult.error}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -899,6 +925,24 @@ export async function processScheduledPosts(): Promise<ProcessScheduledPostsResu
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a bot has any active content sources.
|
||||||
|
*
|
||||||
|
* @param botId - The bot ID
|
||||||
|
* @returns True if bot has active sources
|
||||||
|
*/
|
||||||
|
async function botHasActiveSources(botId: string): Promise<boolean> {
|
||||||
|
const sources = await db.query.botContentSources.findMany({
|
||||||
|
where: and(
|
||||||
|
eq(botContentSources.botId, botId),
|
||||||
|
eq(botContentSources.isActive, true)
|
||||||
|
),
|
||||||
|
columns: { id: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
return sources.length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the schedule configuration for a bot.
|
* Get the schedule configuration for a bot.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -362,6 +362,76 @@ export async function fetchSwarmUserProfile(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cache swarm user posts in the remotePosts table
|
||||||
|
* Similar to cacheRemoteUserPosts but for swarm nodes
|
||||||
|
*/
|
||||||
|
export async function cacheSwarmUserPosts(
|
||||||
|
handle: string,
|
||||||
|
domain: string,
|
||||||
|
fullHandle: string, // e.g., "user@domain.com"
|
||||||
|
limit: number = 20
|
||||||
|
): Promise<{ cached: number; skipped: number }> {
|
||||||
|
try {
|
||||||
|
const profileData = await fetchSwarmUserProfile(handle, domain, limit);
|
||||||
|
|
||||||
|
if (!profileData || !profileData.posts) {
|
||||||
|
return { cached: 0, skipped: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
const { db, remotePosts } = await import('@/db');
|
||||||
|
const { eq } = await import('drizzle-orm');
|
||||||
|
|
||||||
|
if (!db) {
|
||||||
|
return { cached: 0, skipped: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
let cached = 0;
|
||||||
|
let skipped = 0;
|
||||||
|
|
||||||
|
const actorUrl = `swarm://${domain}/${handle}`;
|
||||||
|
const profile = profileData.profile;
|
||||||
|
|
||||||
|
for (const post of profileData.posts) {
|
||||||
|
// Generate a unique AP-style ID for the post
|
||||||
|
const apId = `swarm://${domain}/posts/${post.id}`;
|
||||||
|
|
||||||
|
// Check if we already have this post
|
||||||
|
const existing = await db.query.remotePosts.findFirst({
|
||||||
|
where: eq(remotePosts.apId, apId),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
skipped++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cache the post
|
||||||
|
await db.insert(remotePosts).values({
|
||||||
|
apId,
|
||||||
|
authorHandle: fullHandle,
|
||||||
|
authorActorUrl: actorUrl,
|
||||||
|
authorDisplayName: profile.displayName || handle,
|
||||||
|
authorAvatarUrl: profile.avatarUrl || null,
|
||||||
|
content: post.content,
|
||||||
|
publishedAt: new Date(post.createdAt),
|
||||||
|
linkPreviewUrl: post.linkPreviewUrl || null,
|
||||||
|
linkPreviewTitle: post.linkPreviewTitle || null,
|
||||||
|
linkPreviewDescription: post.linkPreviewDescription || null,
|
||||||
|
linkPreviewImage: post.linkPreviewImage || null,
|
||||||
|
mediaJson: post.media ? JSON.stringify(post.media) : null,
|
||||||
|
});
|
||||||
|
|
||||||
|
cached++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { cached, skipped };
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`[Swarm] Error caching posts for ${fullHandle}:`, error);
|
||||||
|
return { cached: 0, skipped: 0 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetch a single post from a swarm node
|
* Fetch a single post from a swarm node
|
||||||
*/
|
*/
|
||||||
|
|||||||
Reference in New Issue
Block a user