Files
Synapsis/src/lib/bots/botManager.ts
T
Christomatt eb63194c56 refactor: Migrate from ActivityPub federation to native Swarm network
- Remove ActivityPub infrastructure (webfinger, nodeinfo, inbox, activities, signatures)
- Implement native peer-to-peer Swarm network with gossip protocol
- Replace federated timeline with Swarm timeline aggregating posts from all nodes
- Migrate direct messaging to end-to-end encrypted Swarm Chat system
- Update user interactions (follow, like, repost) to use Swarm protocol
- Add cryptographic key management for DID-based identity system
- Remove server-bound identity model in favor of portable DID identities
- Update documentation to reflect Swarm architecture and capabilities
- Add debug utilities and chat testing tools for Swarm network
- Refactor database schema to support distributed user directory
- Update bot framework to work with Swarm network interactions
- Simplify API routes to use Swarm protocol instead of ActivityPub
- This transition enables true peer-to-peer communication, instant interactions, and encrypted messaging while maintaining sovereign identity through DIDs
2026-01-27 01:27:15 +01:00

900 lines
24 KiB
TypeScript

/**
* Bot Manager Service
*
* Core orchestrator for bot lifecycle, configuration, and operations.
* Handles bot CRUD operations, user linking, and cryptographic 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/crypto/keys';
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 cryptographic 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;
}
}