0ecb622565
Hop-State: A_06FP6A2CG8PVBRDVQ5WGCN0 Hop-Proposal: R_06FP6A0YW23RY0N7Z5M8DW0 Hop-Task: T_06FP67JVF4BHPYKKC37XKQ8 Hop-Attempt: AT_06FP69RZ96ZMNF9Y5S02MT0
633 lines
16 KiB
TypeScript
633 lines
16 KiB
TypeScript
/**
|
|
* Mention Handler Module
|
|
*
|
|
* Detects mentions of bots in posts and manages mention responses.
|
|
* Processes mentions in chronological order and respects reply rate limits.
|
|
*
|
|
* Requirements: 7.1, 7.2, 7.3, 7.4, 7.5, 7.6
|
|
*/
|
|
|
|
import { db, bots, botMentions, posts, users } from '@/db';
|
|
import { eq, and, desc, asc, isNull } from 'drizzle-orm';
|
|
import { ContentGenerator, type Bot as GeneratorBot, type Post as GeneratorPost } from './contentGenerator';
|
|
import { canReply, recordReply } from './rateLimiter';
|
|
import { decryptApiKey } from './encryption';
|
|
|
|
// ============================================
|
|
// TYPES
|
|
// ============================================
|
|
|
|
/**
|
|
* Mention data structure.
|
|
*/
|
|
export interface Mention {
|
|
id: string;
|
|
botId: string;
|
|
postId: string;
|
|
authorId: string;
|
|
content: string;
|
|
isProcessed: boolean;
|
|
processedAt: Date | null;
|
|
responsePostId: string | null;
|
|
isRemote: boolean;
|
|
remoteActorUrl: string | null;
|
|
createdAt: Date;
|
|
}
|
|
|
|
/**
|
|
* Post data with author information.
|
|
*/
|
|
export interface PostWithAuthor {
|
|
id: string;
|
|
userId: string;
|
|
content: string;
|
|
replyToId: string | null;
|
|
createdAt: Date;
|
|
author: {
|
|
id: string;
|
|
handle: string;
|
|
displayName: string | null;
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Mention detection result.
|
|
*/
|
|
export interface MentionDetectionResult {
|
|
detected: boolean;
|
|
mentions: Mention[];
|
|
}
|
|
|
|
/**
|
|
* Mention response result.
|
|
*/
|
|
export interface MentionResponseResult {
|
|
success: boolean;
|
|
responsePostId?: string;
|
|
error?: string;
|
|
}
|
|
|
|
/**
|
|
* Error thrown by mention handler operations.
|
|
*/
|
|
export class MentionHandlerError extends Error {
|
|
constructor(
|
|
message: string,
|
|
public code: MentionHandlerErrorCode,
|
|
public cause?: Error
|
|
) {
|
|
super(message);
|
|
this.name = 'MentionHandlerError';
|
|
}
|
|
}
|
|
|
|
export type MentionHandlerErrorCode =
|
|
| 'BOT_NOT_FOUND'
|
|
| 'MENTION_NOT_FOUND'
|
|
| 'POST_NOT_FOUND'
|
|
| 'RATE_LIMITED'
|
|
| 'GENERATION_FAILED'
|
|
| 'DATABASE_ERROR';
|
|
|
|
// ============================================
|
|
// MENTION DETECTION
|
|
// ============================================
|
|
|
|
/**
|
|
* Detect mentions of a bot in posts.
|
|
* Scans posts for the bot's handle and creates mention records.
|
|
*
|
|
* @param botId - The ID of the bot to check for mentions
|
|
* @returns Detection result with found mentions
|
|
*
|
|
* Validates: Requirements 7.1, 7.2
|
|
*/
|
|
export async function detectMentions(botId: string): Promise<MentionDetectionResult> {
|
|
try {
|
|
// Get the bot's handle
|
|
const bot = await db.query.bots.findFirst({
|
|
where: { id: botId },
|
|
with: {
|
|
user: {
|
|
columns: { handle: true },
|
|
},
|
|
},
|
|
columns: { id: true },
|
|
});
|
|
|
|
if (!bot) {
|
|
throw new MentionHandlerError(
|
|
`Bot not found: ${botId}`,
|
|
'BOT_NOT_FOUND'
|
|
);
|
|
}
|
|
|
|
// Get existing mention post IDs to avoid duplicates
|
|
const existingMentions = await db.query.botMentions.findMany({
|
|
where: { botId: botId },
|
|
columns: { postId: true },
|
|
});
|
|
|
|
const existingPostIds = new Set(existingMentions.map(m => m.postId));
|
|
|
|
// Find posts that mention the bot's handle
|
|
// Note: In a production system, this would be more efficient with full-text search
|
|
// or a dedicated mentions table updated on post creation
|
|
const mentionPattern = `@${bot.user.handle}`;
|
|
|
|
// Get recent posts (last 24 hours) that might contain mentions
|
|
const oneDayAgo = new Date();
|
|
oneDayAgo.setDate(oneDayAgo.getDate() - 1);
|
|
|
|
const recentPosts = await db.query.posts.findMany({
|
|
where: { AND: [{ isRemoved: false }] },
|
|
with: {
|
|
author: {
|
|
columns: {
|
|
id: true,
|
|
handle: true,
|
|
displayName: true,
|
|
},
|
|
},
|
|
},
|
|
orderBy: (posts, { desc }) => [desc(posts.createdAt)],
|
|
limit: 1000, // Reasonable limit for scanning
|
|
});
|
|
|
|
// Filter posts that mention the bot and aren't already tracked
|
|
const newMentionPosts = recentPosts.filter(post =>
|
|
post.content.includes(mentionPattern) &&
|
|
!existingPostIds.has(post.id)
|
|
);
|
|
|
|
// Create mention records
|
|
const newMentions: Mention[] = [];
|
|
|
|
for (const post of newMentionPosts) {
|
|
const [mention] = await db.insert(botMentions).values({
|
|
botId,
|
|
postId: post.id,
|
|
authorId: post.userId,
|
|
content: post.content,
|
|
isProcessed: false,
|
|
isRemote: false, // Local mentions for now
|
|
}).returning();
|
|
|
|
newMentions.push({
|
|
id: mention.id,
|
|
botId: mention.botId,
|
|
postId: mention.postId,
|
|
authorId: mention.authorId,
|
|
content: mention.content,
|
|
isProcessed: mention.isProcessed,
|
|
processedAt: mention.processedAt,
|
|
responsePostId: mention.responsePostId,
|
|
isRemote: mention.isRemote,
|
|
remoteActorUrl: mention.remoteActorUrl,
|
|
createdAt: mention.createdAt,
|
|
});
|
|
}
|
|
|
|
return {
|
|
detected: newMentions.length > 0,
|
|
mentions: newMentions,
|
|
};
|
|
} catch (error) {
|
|
if (error instanceof MentionHandlerError) {
|
|
throw error;
|
|
}
|
|
|
|
throw new MentionHandlerError(
|
|
`Failed to detect mentions: ${error instanceof Error ? error.message : String(error)}`,
|
|
'DATABASE_ERROR',
|
|
error instanceof Error ? error : undefined
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get unprocessed mentions for a bot in chronological order.
|
|
*
|
|
* @param botId - The ID of the bot
|
|
* @returns Array of unprocessed mentions, oldest first
|
|
*
|
|
* Validates: Requirements 7.5
|
|
*/
|
|
export async function getUnprocessedMentions(botId: string): Promise<Mention[]> {
|
|
try {
|
|
const mentions = await db.query.botMentions.findMany({
|
|
where: { AND: [{ botId: botId }, { isProcessed: false }] },
|
|
orderBy: (botMentions, { asc }) => [asc(botMentions.createdAt)], // Chronological order (oldest first)
|
|
});
|
|
|
|
return mentions.map(m => ({
|
|
id: m.id,
|
|
botId: m.botId,
|
|
postId: m.postId,
|
|
authorId: m.authorId,
|
|
content: m.content,
|
|
isProcessed: m.isProcessed,
|
|
processedAt: m.processedAt,
|
|
responsePostId: m.responsePostId,
|
|
isRemote: m.isRemote,
|
|
remoteActorUrl: m.remoteActorUrl,
|
|
createdAt: m.createdAt,
|
|
}));
|
|
} catch (error) {
|
|
throw new MentionHandlerError(
|
|
`Failed to get unprocessed mentions: ${error instanceof Error ? error.message : String(error)}`,
|
|
'DATABASE_ERROR',
|
|
error instanceof Error ? error : undefined
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get all mentions for a bot (processed and unprocessed).
|
|
*
|
|
* @param botId - The ID of the bot
|
|
* @returns Array of all mentions, newest first
|
|
*/
|
|
export async function getAllMentions(botId: string): Promise<Mention[]> {
|
|
try {
|
|
const mentions = await db.query.botMentions.findMany({
|
|
where: { botId: botId },
|
|
orderBy: (botMentions, { desc }) => [desc(botMentions.createdAt)],
|
|
});
|
|
|
|
return mentions.map(m => ({
|
|
id: m.id,
|
|
botId: m.botId,
|
|
postId: m.postId,
|
|
authorId: m.authorId,
|
|
content: m.content,
|
|
isProcessed: m.isProcessed,
|
|
processedAt: m.processedAt,
|
|
responsePostId: m.responsePostId,
|
|
isRemote: m.isRemote,
|
|
remoteActorUrl: m.remoteActorUrl,
|
|
createdAt: m.createdAt,
|
|
}));
|
|
} catch (error) {
|
|
throw new MentionHandlerError(
|
|
`Failed to get mentions: ${error instanceof Error ? error.message : String(error)}`,
|
|
'DATABASE_ERROR',
|
|
error instanceof Error ? error : undefined
|
|
);
|
|
}
|
|
}
|
|
|
|
// ============================================
|
|
// CONVERSATION CONTEXT
|
|
// ============================================
|
|
|
|
/**
|
|
* Get conversation context for a mention.
|
|
* Retrieves the thread of posts leading up to the mention.
|
|
*
|
|
* @param postId - The ID of the post containing the mention
|
|
* @param maxDepth - Maximum number of parent posts to retrieve
|
|
* @returns Array of posts in the conversation thread
|
|
*
|
|
* Validates: Requirements 7.4
|
|
*/
|
|
export async function getConversationContext(
|
|
postId: string,
|
|
maxDepth: number = 5
|
|
): Promise<GeneratorPost[]> {
|
|
try {
|
|
const context: GeneratorPost[] = [];
|
|
let currentPostId: string | null = postId;
|
|
let depth = 0;
|
|
|
|
while (currentPostId && depth < maxDepth) {
|
|
const post: any = await db.query.posts.findFirst({
|
|
where: { id: currentPostId },
|
|
with: {
|
|
author: {
|
|
columns: {
|
|
handle: true,
|
|
displayName: true,
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
if (!post) break;
|
|
|
|
// Add to context (we'll reverse later to get chronological order)
|
|
context.push({
|
|
id: post.id,
|
|
userId: post.userId,
|
|
content: post.content,
|
|
createdAt: post.createdAt,
|
|
author: {
|
|
handle: post.author.handle,
|
|
displayName: post.author.displayName,
|
|
},
|
|
});
|
|
|
|
// Move to parent post
|
|
currentPostId = post.replyToId;
|
|
depth++;
|
|
}
|
|
|
|
// Reverse to get chronological order (oldest first)
|
|
return context.reverse();
|
|
} catch (error) {
|
|
throw new MentionHandlerError(
|
|
`Failed to get conversation context: ${error instanceof Error ? error.message : String(error)}`,
|
|
'DATABASE_ERROR',
|
|
error instanceof Error ? error : undefined
|
|
);
|
|
}
|
|
}
|
|
|
|
// ============================================
|
|
// MENTION RESPONSE
|
|
// ============================================
|
|
|
|
/**
|
|
* Process a mention and generate a response.
|
|
* Checks rate limits, generates reply using LLM, and creates response post.
|
|
*
|
|
* @param mentionId - The ID of the mention to process
|
|
* @returns Response result with post ID or error
|
|
*
|
|
* Validates: Requirements 7.3, 7.4, 7.6
|
|
*/
|
|
export async function processMention(mentionId: string): Promise<MentionResponseResult> {
|
|
try {
|
|
// Get the mention
|
|
const mention = await db.query.botMentions.findFirst({
|
|
where: { id: mentionId },
|
|
});
|
|
|
|
if (!mention) {
|
|
throw new MentionHandlerError(
|
|
`Mention not found: ${mentionId}`,
|
|
'MENTION_NOT_FOUND'
|
|
);
|
|
}
|
|
|
|
// Check if already processed
|
|
if (mention.isProcessed) {
|
|
return {
|
|
success: true,
|
|
responsePostId: mention.responsePostId || undefined,
|
|
};
|
|
}
|
|
|
|
// Check rate limits (Requirement 7.6)
|
|
const rateLimitCheck = await canReply(mention.botId);
|
|
if (!rateLimitCheck.allowed) {
|
|
throw new MentionHandlerError(
|
|
rateLimitCheck.reason || 'Rate limit exceeded',
|
|
'RATE_LIMITED'
|
|
);
|
|
}
|
|
|
|
// Get the bot
|
|
const bot = await db.query.bots.findFirst({
|
|
where: { id: mention.botId },
|
|
with: {
|
|
user: {
|
|
columns: {
|
|
id: true,
|
|
handle: true,
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
if (!bot) {
|
|
throw new MentionHandlerError(
|
|
`Bot not found: ${mention.botId}`,
|
|
'BOT_NOT_FOUND'
|
|
);
|
|
}
|
|
|
|
// Get the mentioning post with author info
|
|
const mentionPost = await db.query.posts.findFirst({
|
|
where: { id: mention.postId },
|
|
with: {
|
|
author: {
|
|
columns: {
|
|
handle: true,
|
|
displayName: true,
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
if (!mentionPost) {
|
|
throw new MentionHandlerError(
|
|
`Post not found: ${mention.postId}`,
|
|
'POST_NOT_FOUND'
|
|
);
|
|
}
|
|
|
|
// Get conversation context (Requirement 7.4)
|
|
const conversationContext = await getConversationContext(mention.postId);
|
|
|
|
// Remove the mention post itself from context (it will be passed separately)
|
|
const contextWithoutMention = conversationContext.filter(p => p.id !== mention.postId);
|
|
|
|
// Prepare bot for content generator
|
|
const generatorBot: GeneratorBot = {
|
|
id: bot.id,
|
|
name: bot.name,
|
|
handle: bot.user.handle,
|
|
personalityConfig: JSON.parse(bot.personalityConfig),
|
|
llmProvider: bot.llmProvider as any,
|
|
llmModel: bot.llmModel,
|
|
llmEndpoint: bot.llmEndpoint,
|
|
llmApiKeyEncrypted: bot.llmApiKeyEncrypted,
|
|
};
|
|
|
|
// Prepare mention post for generator
|
|
const generatorMentionPost: GeneratorPost = {
|
|
id: mentionPost.id,
|
|
userId: mentionPost.userId,
|
|
content: mentionPost.content,
|
|
createdAt: mentionPost.createdAt,
|
|
author: {
|
|
handle: mentionPost.author.handle,
|
|
displayName: mentionPost.author.displayName,
|
|
},
|
|
};
|
|
|
|
// Generate reply (Requirement 7.3)
|
|
const generator = new ContentGenerator(generatorBot);
|
|
const generatedReply = await generator.generateReply(
|
|
generatorMentionPost,
|
|
contextWithoutMention
|
|
);
|
|
|
|
// Create response post
|
|
const [responsePost] = await db.insert(posts).values({
|
|
userId: bot.user.id, // Bot posts as its associated user
|
|
content: generatedReply.text,
|
|
replyToId: mention.postId,
|
|
}).returning();
|
|
|
|
// Mark mention as processed
|
|
await db.update(botMentions)
|
|
.set({
|
|
isProcessed: true,
|
|
processedAt: new Date(),
|
|
responsePostId: responsePost.id,
|
|
})
|
|
.where(eq(botMentions.id, mentionId));
|
|
|
|
// Record reply for rate limiting
|
|
await recordReply(mention.botId);
|
|
|
|
return {
|
|
success: true,
|
|
responsePostId: responsePost.id,
|
|
};
|
|
} catch (error) {
|
|
if (error instanceof MentionHandlerError) {
|
|
return {
|
|
success: false,
|
|
error: error.message,
|
|
};
|
|
}
|
|
|
|
return {
|
|
success: false,
|
|
error: error instanceof Error ? error.message : String(error),
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Process all unprocessed mentions for a bot in chronological order.
|
|
* Stops if rate limit is reached.
|
|
*
|
|
* @param botId - The ID of the bot
|
|
* @returns Array of response results
|
|
*
|
|
* Validates: Requirements 7.5, 7.6
|
|
*/
|
|
export async function processAllMentions(botId: string): Promise<MentionResponseResult[]> {
|
|
const mentions = await getUnprocessedMentions(botId);
|
|
const results: MentionResponseResult[] = [];
|
|
|
|
for (const mention of mentions) {
|
|
// Check rate limit before processing each mention
|
|
const rateLimitCheck = await canReply(botId);
|
|
if (!rateLimitCheck.allowed) {
|
|
// Stop processing if rate limited
|
|
results.push({
|
|
success: false,
|
|
error: rateLimitCheck.reason,
|
|
});
|
|
break;
|
|
}
|
|
|
|
const result = await processMention(mention.id);
|
|
results.push(result);
|
|
|
|
// Stop if processing failed
|
|
if (!result.success) {
|
|
break;
|
|
}
|
|
}
|
|
|
|
return results;
|
|
}
|
|
|
|
// ============================================
|
|
// MENTION STORAGE
|
|
// ============================================
|
|
|
|
/**
|
|
* Store a detected mention in the database.
|
|
* Used when mentions are detected from external sources (e.g., swarm nodes).
|
|
*
|
|
* @param data - Mention data to store
|
|
* @returns Created mention
|
|
*
|
|
* Validates: Requirements 7.1, 7.2
|
|
*/
|
|
export async function storeMention(data: {
|
|
botId: string;
|
|
postId: string;
|
|
authorId: string;
|
|
content: string;
|
|
isRemote?: boolean;
|
|
remoteActorUrl?: string;
|
|
}): Promise<Mention> {
|
|
try {
|
|
const [mention] = await db.insert(botMentions).values({
|
|
botId: data.botId,
|
|
postId: data.postId,
|
|
authorId: data.authorId,
|
|
content: data.content,
|
|
isProcessed: false,
|
|
isRemote: data.isRemote || false,
|
|
remoteActorUrl: data.remoteActorUrl || null,
|
|
}).returning();
|
|
|
|
return {
|
|
id: mention.id,
|
|
botId: mention.botId,
|
|
postId: mention.postId,
|
|
authorId: mention.authorId,
|
|
content: mention.content,
|
|
isProcessed: mention.isProcessed,
|
|
processedAt: mention.processedAt,
|
|
responsePostId: mention.responsePostId,
|
|
isRemote: mention.isRemote,
|
|
remoteActorUrl: mention.remoteActorUrl,
|
|
createdAt: mention.createdAt,
|
|
};
|
|
} catch (error) {
|
|
throw new MentionHandlerError(
|
|
`Failed to store mention: ${error instanceof Error ? error.message : String(error)}`,
|
|
'DATABASE_ERROR',
|
|
error instanceof Error ? error : undefined
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get a mention by ID.
|
|
*
|
|
* @param mentionId - The ID of the mention
|
|
* @returns Mention data or null if not found
|
|
*/
|
|
export async function getMentionById(mentionId: string): Promise<Mention | null> {
|
|
try {
|
|
const mention = await db.query.botMentions.findFirst({
|
|
where: { id: mentionId },
|
|
});
|
|
|
|
if (!mention) {
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
id: mention.id,
|
|
botId: mention.botId,
|
|
postId: mention.postId,
|
|
authorId: mention.authorId,
|
|
content: mention.content,
|
|
isProcessed: mention.isProcessed,
|
|
processedAt: mention.processedAt,
|
|
responsePostId: mention.responsePostId,
|
|
isRemote: mention.isRemote,
|
|
remoteActorUrl: mention.remoteActorUrl,
|
|
createdAt: mention.createdAt,
|
|
};
|
|
} catch (error) {
|
|
throw new MentionHandlerError(
|
|
`Failed to get mention: ${error instanceof Error ? error.message : String(error)}`,
|
|
'DATABASE_ERROR',
|
|
error instanceof Error ? error : undefined
|
|
);
|
|
}
|
|
}
|