feat(bots): Implement comprehensive bot system with autonomous posting, content management, and API endpoints
- Add bot management system with creation, suspension, and reinstatement functionality - Implement autonomous bot posting with scheduling, rate limiting, and content generation - Add content fetching system supporting RSS feeds and multiple content sources - Implement LLM-based content generation with customizable bot personalities - Add mention handling and automated response system for bot interactions - Implement API key management with encryption using AUTH_SECRET for simplified deployment - Add comprehensive bot logging system for activity tracking and error monitoring - Create bot administration pages and settings UI for managing bot configurations - Add database migrations for bot system schema including users, sources, and content items - Implement cron job system for automated bot operations and scheduled tasks - Add extensive test coverage with unit and property-based tests for core bot modules - Simplify encryption by deriving keys from AUTH_SECRET instead of separate environment variable - Implement automatic content fetching on post trigger with retry logic - Add Reddit-specific link preview handling using oEmbed API for reliable metadata extraction - Create utility scripts for bot inspection and cleanup operations - Add comprehensive bot system documentation and improvement tracking
This commit is contained in:
@@ -0,0 +1,899 @@
|
||||
/**
|
||||
* Bot Manager Service
|
||||
*
|
||||
* Core orchestrator for bot lifecycle, configuration, and operations.
|
||||
* Handles bot CRUD operations, user linking, and ActivityPub key generation.
|
||||
*
|
||||
* Bots are first-class users with their own profiles, handles, and posts.
|
||||
* Each bot has an owner (human user) who manages it.
|
||||
*
|
||||
* Requirements: 1.1, 1.2, 1.3, 1.4, 1.5, 1.6
|
||||
*/
|
||||
|
||||
import { db, bots, users, botContentSources, botContentItems, botMentions, botActivityLogs, botRateLimits, follows } from '@/db';
|
||||
import { eq, and, count } from 'drizzle-orm';
|
||||
import { generateKeyPair } from '@/lib/activitypub/signatures';
|
||||
import {
|
||||
encryptApiKey,
|
||||
decryptApiKey,
|
||||
serializeEncryptedData,
|
||||
deserializeEncryptedData,
|
||||
validateApiKeyFormat,
|
||||
type LLMProvider
|
||||
} from './encryption';
|
||||
|
||||
// ============================================
|
||||
// TYPES
|
||||
// ============================================
|
||||
|
||||
export interface PersonalityConfig {
|
||||
systemPrompt: string;
|
||||
temperature: number;
|
||||
maxTokens: number;
|
||||
responseStyle?: string;
|
||||
}
|
||||
|
||||
export interface ScheduleConfig {
|
||||
type: 'interval' | 'times' | 'cron';
|
||||
intervalMinutes?: number;
|
||||
times?: string[]; // HH:MM format
|
||||
cronExpression?: string;
|
||||
timezone?: string;
|
||||
}
|
||||
|
||||
export interface BotCreateInput {
|
||||
name: string;
|
||||
handle: string;
|
||||
bio?: string;
|
||||
avatarUrl?: string;
|
||||
headerUrl?: string;
|
||||
personality: PersonalityConfig;
|
||||
llmProvider: LLMProvider;
|
||||
llmModel: string;
|
||||
llmApiKey: string;
|
||||
schedule?: ScheduleConfig;
|
||||
autonomousMode?: boolean;
|
||||
}
|
||||
|
||||
export interface BotUpdateInput {
|
||||
name?: string;
|
||||
bio?: string;
|
||||
avatarUrl?: string;
|
||||
headerUrl?: string;
|
||||
personality?: PersonalityConfig;
|
||||
llmProvider?: LLMProvider;
|
||||
llmModel?: string;
|
||||
llmApiKey?: string;
|
||||
schedule?: ScheduleConfig | null;
|
||||
autonomousMode?: boolean;
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
export interface Bot {
|
||||
id: string;
|
||||
userId: string; // The bot's own user account
|
||||
ownerId: string; // The human who manages this bot
|
||||
name: string;
|
||||
handle: string;
|
||||
bio: string | null;
|
||||
avatarUrl: string | null;
|
||||
headerUrl: string | null;
|
||||
personalityConfig: PersonalityConfig;
|
||||
llmProvider: LLMProvider;
|
||||
llmModel: string;
|
||||
scheduleConfig: ScheduleConfig | null;
|
||||
autonomousMode: boolean;
|
||||
isActive: boolean;
|
||||
isSuspended: boolean;
|
||||
suspensionReason: string | null;
|
||||
suspendedAt: Date | null;
|
||||
publicKey: string;
|
||||
lastPostAt: Date | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// CONSTANTS
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* Default maximum number of bots per user.
|
||||
* Can be overridden via BOT_MAX_PER_USER environment variable.
|
||||
*/
|
||||
const DEFAULT_MAX_BOTS_PER_USER = 5;
|
||||
|
||||
/**
|
||||
* Get the maximum bots per user from environment or default.
|
||||
*/
|
||||
export function getMaxBotsPerUser(): number {
|
||||
const envValue = process.env.BOT_MAX_PER_USER;
|
||||
if (envValue) {
|
||||
const parsed = parseInt(envValue, 10);
|
||||
if (!isNaN(parsed) && parsed > 0) {
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
return DEFAULT_MAX_BOTS_PER_USER;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// ERROR CLASSES
|
||||
// ============================================
|
||||
|
||||
export class BotError extends Error {
|
||||
constructor(message: string, public code: string) {
|
||||
super(message);
|
||||
this.name = 'BotError';
|
||||
}
|
||||
}
|
||||
|
||||
export class BotNotFoundError extends BotError {
|
||||
constructor(botId: string) {
|
||||
super(`Bot not found: ${botId}`, 'BOT_NOT_FOUND');
|
||||
}
|
||||
}
|
||||
|
||||
export class BotLimitExceededError extends BotError {
|
||||
constructor(userId: string, limit: number) {
|
||||
super(`User ${userId} has reached the maximum bot limit of ${limit}`, 'BOT_LIMIT_EXCEEDED');
|
||||
}
|
||||
}
|
||||
|
||||
export class BotHandleTakenError extends BotError {
|
||||
constructor(handle: string) {
|
||||
super(`Bot handle is already taken: ${handle}`, 'BOT_HANDLE_TAKEN');
|
||||
}
|
||||
}
|
||||
|
||||
export class BotValidationError extends BotError {
|
||||
constructor(message: string) {
|
||||
super(message, 'BOT_VALIDATION_ERROR');
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// VALIDATION FUNCTIONS
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* Validate bot handle format.
|
||||
* Handles must be 3-30 characters, alphanumeric and underscores only.
|
||||
*/
|
||||
export function validateBotHandle(handle: string): void {
|
||||
if (!handle || typeof handle !== 'string') {
|
||||
throw new BotValidationError('Bot handle is required');
|
||||
}
|
||||
|
||||
if (!/^[a-zA-Z0-9_]{3,30}$/.test(handle)) {
|
||||
throw new BotValidationError('Bot handle must be 3-30 characters, alphanumeric and underscores only');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate bot name.
|
||||
*/
|
||||
export function validateBotName(name: string): void {
|
||||
if (!name || typeof name !== 'string') {
|
||||
throw new BotValidationError('Bot name is required');
|
||||
}
|
||||
|
||||
if (name.length < 1 || name.length > 100) {
|
||||
throw new BotValidationError('Bot name must be 1-100 characters');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate personality configuration.
|
||||
*/
|
||||
export function validatePersonalityConfig(config: PersonalityConfig): void {
|
||||
if (!config || typeof config !== 'object') {
|
||||
throw new BotValidationError('Personality configuration is required');
|
||||
}
|
||||
|
||||
if (!config.systemPrompt || typeof config.systemPrompt !== 'string') {
|
||||
throw new BotValidationError('System prompt is required');
|
||||
}
|
||||
|
||||
if (config.systemPrompt.length > 10000) {
|
||||
throw new BotValidationError('System prompt must be 10000 characters or less');
|
||||
}
|
||||
|
||||
if (typeof config.temperature !== 'number' || config.temperature < 0 || config.temperature > 2) {
|
||||
throw new BotValidationError('Temperature must be a number between 0 and 2');
|
||||
}
|
||||
|
||||
if (typeof config.maxTokens !== 'number' || config.maxTokens < 1 || config.maxTokens > 100000) {
|
||||
throw new BotValidationError('Max tokens must be a number between 1 and 100000');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate schedule configuration.
|
||||
*/
|
||||
export function validateScheduleConfig(config: ScheduleConfig): void {
|
||||
if (!config || typeof config !== 'object') {
|
||||
throw new BotValidationError('Schedule configuration is required');
|
||||
}
|
||||
|
||||
if (!['interval', 'times', 'cron'].includes(config.type)) {
|
||||
throw new BotValidationError('Schedule type must be interval, times, or cron');
|
||||
}
|
||||
|
||||
if (config.type === 'interval') {
|
||||
if (typeof config.intervalMinutes !== 'number' || config.intervalMinutes < 5) {
|
||||
throw new BotValidationError('Interval must be at least 5 minutes');
|
||||
}
|
||||
}
|
||||
|
||||
if (config.type === 'times') {
|
||||
if (!Array.isArray(config.times) || config.times.length === 0) {
|
||||
throw new BotValidationError('Times array is required for times schedule type');
|
||||
}
|
||||
|
||||
const timePattern = /^([01][0-9]|2[0-3]):[0-5][0-9]$/;
|
||||
for (const time of config.times) {
|
||||
if (!timePattern.test(time)) {
|
||||
throw new BotValidationError(`Invalid time format: ${time}. Use HH:MM format`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (config.type === 'cron') {
|
||||
if (!config.cronExpression || typeof config.cronExpression !== 'string') {
|
||||
throw new BotValidationError('Cron expression is required for cron schedule type');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// HELPER FUNCTIONS
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* Convert database bot row to Bot interface.
|
||||
* Requires the bot's user account to be loaded.
|
||||
*/
|
||||
function dbBotToBot(dbBot: typeof bots.$inferSelect, botUser: typeof users.$inferSelect): Bot {
|
||||
return {
|
||||
id: dbBot.id,
|
||||
userId: dbBot.userId,
|
||||
ownerId: dbBot.ownerId,
|
||||
name: dbBot.name,
|
||||
handle: botUser.handle,
|
||||
bio: botUser.bio,
|
||||
avatarUrl: botUser.avatarUrl,
|
||||
headerUrl: botUser.headerUrl,
|
||||
personalityConfig: JSON.parse(dbBot.personalityConfig) as PersonalityConfig,
|
||||
llmProvider: dbBot.llmProvider as LLMProvider,
|
||||
llmModel: dbBot.llmModel,
|
||||
scheduleConfig: dbBot.scheduleConfig ? JSON.parse(dbBot.scheduleConfig) as ScheduleConfig : null,
|
||||
autonomousMode: dbBot.autonomousMode,
|
||||
isActive: dbBot.isActive,
|
||||
isSuspended: dbBot.isSuspended,
|
||||
suspensionReason: dbBot.suspensionReason,
|
||||
suspendedAt: dbBot.suspendedAt,
|
||||
publicKey: botUser.publicKey,
|
||||
lastPostAt: dbBot.lastPostAt,
|
||||
createdAt: dbBot.createdAt,
|
||||
updatedAt: dbBot.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// BOT MANAGER FUNCTIONS
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* Create a new bot for a user.
|
||||
* Creates a user account for the bot with isBot=true.
|
||||
*
|
||||
* @param ownerId - The ID of the user creating/owning the bot
|
||||
* @param config - Bot configuration
|
||||
* @returns The created bot
|
||||
* @throws BotLimitExceededError if user has reached max bots
|
||||
* @throws BotHandleTakenError if handle is already taken
|
||||
* @throws BotValidationError if configuration is invalid
|
||||
*
|
||||
* Validates: Requirements 1.1, 1.2, 1.6
|
||||
*/
|
||||
export async function createBot(ownerId: string, config: BotCreateInput): Promise<Bot> {
|
||||
// Validate inputs
|
||||
validateBotHandle(config.handle);
|
||||
validateBotName(config.name);
|
||||
validatePersonalityConfig(config.personality);
|
||||
|
||||
if (config.schedule) {
|
||||
validateScheduleConfig(config.schedule);
|
||||
}
|
||||
|
||||
// Validate API key format
|
||||
const apiKeyValidation = validateApiKeyFormat(config.llmApiKey, config.llmProvider);
|
||||
if (!apiKeyValidation.valid) {
|
||||
throw new BotValidationError(apiKeyValidation.error || 'Invalid API key');
|
||||
}
|
||||
|
||||
// Check bot limit for user
|
||||
const maxBots = getMaxBotsPerUser();
|
||||
const [botCountResult] = await db
|
||||
.select({ count: count() })
|
||||
.from(bots)
|
||||
.where(eq(bots.ownerId, ownerId));
|
||||
|
||||
if (botCountResult.count >= maxBots) {
|
||||
throw new BotLimitExceededError(ownerId, maxBots);
|
||||
}
|
||||
|
||||
// Check if handle is taken (in users table now)
|
||||
const existingUser = await db.query.users.findFirst({
|
||||
where: eq(users.handle, config.handle.toLowerCase()),
|
||||
});
|
||||
|
||||
if (existingUser) {
|
||||
throw new BotHandleTakenError(config.handle);
|
||||
}
|
||||
|
||||
// Generate ActivityPub keys for the bot's user account
|
||||
const { publicKey, privateKey } = await generateKeyPair();
|
||||
|
||||
// Encrypt the API key and private key
|
||||
const encryptedApiKey = encryptApiKey(config.llmApiKey);
|
||||
const encryptedPrivateKey = encryptApiKey(privateKey);
|
||||
|
||||
// Generate a DID for the bot
|
||||
const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:3000';
|
||||
const botDid = `did:web:${nodeDomain}:users:${config.handle.toLowerCase()}`;
|
||||
|
||||
// Create the bot's user account first
|
||||
const [botUser] = await db.insert(users).values({
|
||||
did: botDid,
|
||||
handle: config.handle.toLowerCase(),
|
||||
displayName: config.name,
|
||||
bio: config.bio || null,
|
||||
avatarUrl: config.avatarUrl || null,
|
||||
headerUrl: config.headerUrl || null,
|
||||
publicKey,
|
||||
privateKeyEncrypted: serializeEncryptedData(encryptedPrivateKey),
|
||||
isBot: true,
|
||||
botOwnerId: ownerId,
|
||||
}).returning();
|
||||
|
||||
// Create the bot configuration
|
||||
const [createdBot] = await db.insert(bots).values({
|
||||
userId: botUser.id, // The bot's own user account
|
||||
ownerId, // The human who manages this bot
|
||||
name: config.name,
|
||||
personalityConfig: JSON.stringify(config.personality),
|
||||
llmProvider: config.llmProvider,
|
||||
llmModel: config.llmModel,
|
||||
llmApiKeyEncrypted: serializeEncryptedData(encryptedApiKey),
|
||||
scheduleConfig: config.schedule ? JSON.stringify(config.schedule) : null,
|
||||
autonomousMode: config.autonomousMode ?? false,
|
||||
isActive: true,
|
||||
isSuspended: false,
|
||||
}).returning();
|
||||
|
||||
// Auto-follow the bot so its posts appear in the owner's home feed
|
||||
await db.insert(follows).values({
|
||||
followerId: ownerId,
|
||||
followingId: botUser.id,
|
||||
});
|
||||
|
||||
// Update follower/following counts
|
||||
await db.update(users)
|
||||
.set({ followersCount: 1 })
|
||||
.where(eq(users.id, botUser.id));
|
||||
|
||||
const owner = await db.query.users.findFirst({
|
||||
where: eq(users.id, ownerId),
|
||||
});
|
||||
if (owner) {
|
||||
await db.update(users)
|
||||
.set({ followingCount: owner.followingCount + 1 })
|
||||
.where(eq(users.id, ownerId));
|
||||
}
|
||||
|
||||
return dbBotToBot(createdBot, botUser);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an existing bot's configuration.
|
||||
* Updates both the bot config and the bot's user account.
|
||||
*
|
||||
* @param botId - The ID of the bot to update
|
||||
* @param config - Updated configuration
|
||||
* @returns The updated bot
|
||||
* @throws BotNotFoundError if bot doesn't exist
|
||||
* @throws BotValidationError if configuration is invalid
|
||||
*
|
||||
* Validates: Requirements 1.1
|
||||
*/
|
||||
export async function updateBot(botId: string, config: BotUpdateInput): Promise<Bot> {
|
||||
// Validate inputs if provided
|
||||
if (config.name !== undefined) {
|
||||
validateBotName(config.name);
|
||||
}
|
||||
|
||||
if (config.personality !== undefined) {
|
||||
validatePersonalityConfig(config.personality);
|
||||
}
|
||||
|
||||
if (config.schedule !== undefined && config.schedule !== null) {
|
||||
validateScheduleConfig(config.schedule);
|
||||
}
|
||||
|
||||
// Validate API key if provided
|
||||
if (config.llmApiKey !== undefined && config.llmProvider !== undefined) {
|
||||
const apiKeyValidation = validateApiKeyFormat(config.llmApiKey, config.llmProvider);
|
||||
if (!apiKeyValidation.valid) {
|
||||
throw new BotValidationError(apiKeyValidation.error || 'Invalid API key');
|
||||
}
|
||||
}
|
||||
|
||||
// Check if bot exists and get its user account
|
||||
const existingBot = await db.query.bots.findFirst({
|
||||
where: eq(bots.id, botId),
|
||||
});
|
||||
|
||||
if (!existingBot) {
|
||||
throw new BotNotFoundError(botId);
|
||||
}
|
||||
|
||||
// Get the bot's user account
|
||||
const botUser = await db.query.users.findFirst({
|
||||
where: eq(users.id, existingBot.userId),
|
||||
});
|
||||
|
||||
if (!botUser) {
|
||||
throw new BotNotFoundError(botId);
|
||||
}
|
||||
|
||||
// Build update object for bot config
|
||||
const botUpdateData: Partial<typeof bots.$inferInsert> = {
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
// Build update object for bot's user account
|
||||
const userUpdateData: Partial<typeof users.$inferInsert> = {
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
if (config.name !== undefined) {
|
||||
botUpdateData.name = config.name;
|
||||
userUpdateData.displayName = config.name;
|
||||
}
|
||||
|
||||
if (config.bio !== undefined) {
|
||||
userUpdateData.bio = config.bio;
|
||||
}
|
||||
|
||||
if (config.avatarUrl !== undefined) {
|
||||
userUpdateData.avatarUrl = config.avatarUrl;
|
||||
}
|
||||
|
||||
if (config.headerUrl !== undefined) {
|
||||
userUpdateData.headerUrl = config.headerUrl;
|
||||
}
|
||||
|
||||
if (config.personality !== undefined) {
|
||||
botUpdateData.personalityConfig = JSON.stringify(config.personality);
|
||||
}
|
||||
|
||||
if (config.llmProvider !== undefined) {
|
||||
botUpdateData.llmProvider = config.llmProvider;
|
||||
}
|
||||
|
||||
if (config.llmModel !== undefined) {
|
||||
botUpdateData.llmModel = config.llmModel;
|
||||
}
|
||||
|
||||
if (config.llmApiKey !== undefined) {
|
||||
const encryptedApiKey = encryptApiKey(config.llmApiKey);
|
||||
botUpdateData.llmApiKeyEncrypted = serializeEncryptedData(encryptedApiKey);
|
||||
}
|
||||
|
||||
if (config.schedule !== undefined) {
|
||||
botUpdateData.scheduleConfig = config.schedule ? JSON.stringify(config.schedule) : null;
|
||||
}
|
||||
|
||||
if (config.autonomousMode !== undefined) {
|
||||
botUpdateData.autonomousMode = config.autonomousMode;
|
||||
}
|
||||
|
||||
if (config.isActive !== undefined) {
|
||||
botUpdateData.isActive = config.isActive;
|
||||
}
|
||||
|
||||
// Update the bot's user account if there are changes
|
||||
if (Object.keys(userUpdateData).length > 1) { // More than just updatedAt
|
||||
await db
|
||||
.update(users)
|
||||
.set(userUpdateData)
|
||||
.where(eq(users.id, existingBot.userId));
|
||||
}
|
||||
|
||||
// Update the bot config
|
||||
const [updatedBot] = await db
|
||||
.update(bots)
|
||||
.set(botUpdateData)
|
||||
.where(eq(bots.id, botId))
|
||||
.returning();
|
||||
|
||||
// Get updated user
|
||||
const updatedUser = await db.query.users.findFirst({
|
||||
where: eq(users.id, existingBot.userId),
|
||||
});
|
||||
|
||||
return dbBotToBot(updatedBot, updatedUser!);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a bot and all associated data.
|
||||
* Also deletes the bot's user account.
|
||||
*
|
||||
* @param botId - The ID of the bot to delete
|
||||
* @throws BotNotFoundError if bot doesn't exist
|
||||
*
|
||||
* Validates: Requirements 1.4
|
||||
*/
|
||||
export async function deleteBot(botId: string): Promise<void> {
|
||||
// Check if bot exists
|
||||
const existingBot = await db.query.bots.findFirst({
|
||||
where: eq(bots.id, botId),
|
||||
});
|
||||
|
||||
if (!existingBot) {
|
||||
throw new BotNotFoundError(botId);
|
||||
}
|
||||
|
||||
// Delete the bot config first (cascade will handle related data)
|
||||
await db.delete(bots).where(eq(bots.id, botId));
|
||||
|
||||
// Delete the bot's user account (cascade will handle posts, follows, etc.)
|
||||
await db.delete(users).where(eq(users.id, existingBot.userId));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all bots owned by a user.
|
||||
*
|
||||
* @param ownerId - The ID of the owner
|
||||
* @returns Array of bots belonging to the user
|
||||
*
|
||||
* Validates: Requirements 1.3
|
||||
*/
|
||||
export async function getBotsByUser(ownerId: string): Promise<Bot[]> {
|
||||
const userBots = await db.query.bots.findMany({
|
||||
where: eq(bots.ownerId, ownerId),
|
||||
orderBy: (bots, { desc }) => [desc(bots.createdAt)],
|
||||
});
|
||||
|
||||
// Get all bot user accounts
|
||||
const botUserIds = userBots.map(b => b.userId);
|
||||
const botUsers = await db.query.users.findMany({
|
||||
where: (users, { inArray }) => inArray(users.id, botUserIds),
|
||||
});
|
||||
|
||||
const userMap = new Map(botUsers.map(u => [u.id, u]));
|
||||
|
||||
return userBots.map(bot => {
|
||||
const botUser = userMap.get(bot.userId);
|
||||
if (!botUser) {
|
||||
throw new Error(`Bot user not found for bot ${bot.id}`);
|
||||
}
|
||||
return dbBotToBot(bot, botUser);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a bot by ID.
|
||||
*
|
||||
* @param botId - The ID of the bot
|
||||
* @returns The bot or null if not found
|
||||
*
|
||||
* Validates: Requirements 1.3
|
||||
*/
|
||||
export async function getBotById(botId: string): Promise<Bot | null> {
|
||||
const bot = await db.query.bots.findFirst({
|
||||
where: eq(bots.id, botId),
|
||||
});
|
||||
|
||||
if (!bot) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Get the bot's user account
|
||||
const botUser = await db.query.users.findFirst({
|
||||
where: eq(users.id, bot.userId),
|
||||
});
|
||||
|
||||
if (!botUser) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return dbBotToBot(bot, botUser);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a bot by handle.
|
||||
*
|
||||
* @param handle - The handle of the bot
|
||||
* @returns The bot or null if not found
|
||||
*/
|
||||
export async function getBotByHandle(handle: string): Promise<Bot | null> {
|
||||
// Find the user with this handle that is a bot
|
||||
const botUser = await db.query.users.findFirst({
|
||||
where: and(
|
||||
eq(users.handle, handle.toLowerCase()),
|
||||
eq(users.isBot, true)
|
||||
),
|
||||
});
|
||||
|
||||
if (!botUser) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Find the bot config for this user
|
||||
const bot = await db.query.bots.findFirst({
|
||||
where: eq(bots.userId, botUser.id),
|
||||
});
|
||||
|
||||
if (!bot) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return dbBotToBot(bot, botUser);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the count of bots for a user.
|
||||
*
|
||||
* @param ownerId - The ID of the owner
|
||||
* @returns The number of bots the user has
|
||||
*/
|
||||
export async function getBotCountForUser(ownerId: string): Promise<number> {
|
||||
const [result] = await db
|
||||
.select({ count: count() })
|
||||
.from(bots)
|
||||
.where(eq(bots.ownerId, ownerId));
|
||||
|
||||
return result.count;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a user can create more bots.
|
||||
*
|
||||
* @param ownerId - The ID of the owner
|
||||
* @returns True if user can create more bots
|
||||
*
|
||||
* Validates: Requirements 1.6
|
||||
*/
|
||||
export async function canUserCreateBot(ownerId: string): Promise<boolean> {
|
||||
const botCount = await getBotCountForUser(ownerId);
|
||||
return botCount < getMaxBotsPerUser();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a user owns a specific bot.
|
||||
*
|
||||
* @param ownerId - The ID of the owner
|
||||
* @param botId - The ID of the bot
|
||||
* @returns True if the user owns the bot
|
||||
*/
|
||||
export async function userOwnsBot(ownerId: string, botId: string): Promise<boolean> {
|
||||
const bot = await db.query.bots.findFirst({
|
||||
where: and(eq(bots.id, botId), eq(bots.ownerId, ownerId)),
|
||||
});
|
||||
|
||||
return bot !== undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the last post timestamp for a bot.
|
||||
*
|
||||
* @param botId - The ID of the bot
|
||||
*/
|
||||
export async function updateBotLastPostAt(botId: string): Promise<void> {
|
||||
await db
|
||||
.update(bots)
|
||||
.set({ lastPostAt: new Date(), updatedAt: new Date() })
|
||||
.where(eq(bots.id, botId));
|
||||
}
|
||||
|
||||
|
||||
// ============================================
|
||||
// API KEY MANAGEMENT FUNCTIONS
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* Marker value used to indicate a removed/empty API key.
|
||||
* This is encrypted and stored when an API key is removed.
|
||||
*/
|
||||
const EMPTY_API_KEY_MARKER = '__REMOVED__';
|
||||
|
||||
/**
|
||||
* Set or update the API key for a bot.
|
||||
*
|
||||
* @param botId - The ID of the bot
|
||||
* @param apiKey - The new API key
|
||||
* @param provider - Optional provider override (uses bot's current provider if not specified)
|
||||
* @throws BotNotFoundError if bot doesn't exist
|
||||
* @throws BotValidationError if API key format is invalid
|
||||
*
|
||||
* Validates: Requirements 2.1, 2.2, 2.4
|
||||
*/
|
||||
export async function setApiKey(
|
||||
botId: string,
|
||||
apiKey: string,
|
||||
provider?: LLMProvider
|
||||
): Promise<void> {
|
||||
// Check if bot exists
|
||||
const existingBot = await db.query.bots.findFirst({
|
||||
where: eq(bots.id, botId),
|
||||
});
|
||||
|
||||
if (!existingBot) {
|
||||
throw new BotNotFoundError(botId);
|
||||
}
|
||||
|
||||
// Use provided provider or bot's current provider
|
||||
const targetProvider = provider || existingBot.llmProvider as LLMProvider;
|
||||
|
||||
// Validate API key format
|
||||
const validation = validateApiKeyFormat(apiKey, targetProvider);
|
||||
if (!validation.valid) {
|
||||
throw new BotValidationError(validation.error || 'Invalid API key format');
|
||||
}
|
||||
|
||||
// Encrypt the API key
|
||||
const encryptedApiKey = encryptApiKey(apiKey);
|
||||
|
||||
// Update the bot with the new API key
|
||||
const updateData: Partial<typeof bots.$inferInsert> = {
|
||||
llmApiKeyEncrypted: serializeEncryptedData(encryptedApiKey),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
// If provider was specified and different, update it too
|
||||
if (provider && provider !== existingBot.llmProvider) {
|
||||
updateData.llmProvider = provider;
|
||||
}
|
||||
|
||||
await db
|
||||
.update(bots)
|
||||
.set(updateData)
|
||||
.where(eq(bots.id, botId));
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the API key from a bot.
|
||||
* Since the llmApiKeyEncrypted field is NOT NULL, this sets it to an
|
||||
* encrypted marker value that indicates the key has been removed.
|
||||
*
|
||||
* @param botId - The ID of the bot
|
||||
* @throws BotNotFoundError if bot doesn't exist
|
||||
*
|
||||
* Validates: Requirements 2.4, 2.5
|
||||
*/
|
||||
export async function removeApiKey(botId: string): Promise<void> {
|
||||
// Check if bot exists
|
||||
const existingBot = await db.query.bots.findFirst({
|
||||
where: eq(bots.id, botId),
|
||||
});
|
||||
|
||||
if (!existingBot) {
|
||||
throw new BotNotFoundError(botId);
|
||||
}
|
||||
|
||||
// Encrypt the marker value to indicate removed key
|
||||
const encryptedMarker = encryptApiKey(EMPTY_API_KEY_MARKER);
|
||||
|
||||
// Update the bot with the marker
|
||||
await db
|
||||
.update(bots)
|
||||
.set({
|
||||
llmApiKeyEncrypted: serializeEncryptedData(encryptedMarker),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(bots.id, botId));
|
||||
}
|
||||
|
||||
/**
|
||||
* API key status information
|
||||
*/
|
||||
export interface ApiKeyStatus {
|
||||
/** Whether an API key is configured (not removed) */
|
||||
hasApiKey: boolean;
|
||||
/** The LLM provider configured for the bot */
|
||||
provider: LLMProvider;
|
||||
/** The LLM model configured for the bot */
|
||||
model: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the API key status for a bot.
|
||||
* Returns whether an API key is configured (not the key itself).
|
||||
*
|
||||
* @param botId - The ID of the bot
|
||||
* @returns API key status information
|
||||
* @throws BotNotFoundError if bot doesn't exist
|
||||
*
|
||||
* Validates: Requirements 2.1, 2.2
|
||||
*/
|
||||
export async function getApiKeyStatus(botId: string): Promise<ApiKeyStatus> {
|
||||
// Get the bot with encrypted API key
|
||||
const bot = await db.query.bots.findFirst({
|
||||
where: eq(bots.id, botId),
|
||||
});
|
||||
|
||||
if (!bot) {
|
||||
throw new BotNotFoundError(botId);
|
||||
}
|
||||
|
||||
// Check if API key is configured (not the removed marker)
|
||||
let hasApiKey = false;
|
||||
|
||||
try {
|
||||
const encryptedData = deserializeEncryptedData(bot.llmApiKeyEncrypted);
|
||||
const decryptedKey = decryptApiKey(encryptedData);
|
||||
hasApiKey = decryptedKey !== EMPTY_API_KEY_MARKER && decryptedKey.length > 0;
|
||||
} catch {
|
||||
// If decryption fails, consider key as not configured
|
||||
hasApiKey = false;
|
||||
}
|
||||
|
||||
return {
|
||||
hasApiKey,
|
||||
provider: bot.llmProvider as LLMProvider,
|
||||
model: bot.llmModel,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a bot has a valid API key configured.
|
||||
*
|
||||
* @param botId - The ID of the bot
|
||||
* @returns True if the bot has a valid API key
|
||||
*/
|
||||
export async function botHasApiKey(botId: string): Promise<boolean> {
|
||||
try {
|
||||
const status = await getApiKeyStatus(botId);
|
||||
return status.hasApiKey;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the decrypted API key for a bot (for internal use only).
|
||||
* This should only be used by the content generator when making LLM calls.
|
||||
*
|
||||
* @param botId - The ID of the bot
|
||||
* @returns The decrypted API key or null if not configured
|
||||
* @throws BotNotFoundError if bot doesn't exist
|
||||
*
|
||||
* Validates: Requirements 2.3
|
||||
*/
|
||||
export async function getDecryptedApiKey(botId: string): Promise<string | null> {
|
||||
// Get the bot with encrypted API key
|
||||
const bot = await db.query.bots.findFirst({
|
||||
where: eq(bots.id, botId),
|
||||
});
|
||||
|
||||
if (!bot) {
|
||||
throw new BotNotFoundError(botId);
|
||||
}
|
||||
|
||||
try {
|
||||
const encryptedData = deserializeEncryptedData(bot.llmApiKeyEncrypted);
|
||||
const decryptedKey = decryptApiKey(encryptedData);
|
||||
|
||||
// Return null if it's the removed marker
|
||||
if (decryptedKey === EMPTY_API_KEY_MARKER) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return decryptedKey;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user