From 8ad3b97b7efd45ebd8a3087250a54dbe3df01aa7 Mon Sep 17 00:00:00 2001 From: AskIt Date: Sun, 25 Jan 2026 16:22:41 +0100 Subject: [PATCH] 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 --- .kiro/specs/bot-system/IMPROVEMENTS.md | 119 + .kiro/specs/bot-system/design.md | 833 +++++ .kiro/specs/bot-system/requirements.md | 174 + .kiro/specs/bot-system/tasks.md | 428 +++ .vscode/settings.json | 3 + bot-cron.ts | 63 + clear_bots.ts | 11 + drizzle/0000_clumsy_donald_blake.sql | 369 +++ drizzle/0001_remove_fetch_interval.sql | 2 + drizzle/0002_add_bot_id_to_posts.sql | 5 + drizzle/0003_fix_bot_content_items_fk.sql | 4 + drizzle/0004_add_source_config.sql | 2 + drizzle/0005_bots_as_users.sql | 34 + drizzle/meta/0000_snapshot.json | 2897 +++++++++++++++++ drizzle/meta/_journal.json | 20 + inspect_bots.ts | 13 + package-lock.json | 1925 ++++++++++- package.json | 11 +- src/app/[handle]/page.tsx | 61 +- src/app/admin/page.tsx | 22 +- src/app/api/account/export/route.ts | 13 +- src/app/api/admin/posts/route.ts | 27 +- src/app/api/admin/reports/route.ts | 67 +- src/app/api/admin/users/route.ts | 1 + src/app/api/bots/[id]/api-key/route.ts | 152 + src/app/api/bots/[id]/api-key/status/route.ts | 70 + src/app/api/bots/[id]/logs/errors/route.ts | 65 + src/app/api/bots/[id]/logs/route.ts | 75 + .../bots/[id]/mentions/[mid]/respond/route.ts | 117 + src/app/api/bots/[id]/mentions/route.ts | 85 + src/app/api/bots/[id]/post/route.test.ts | 369 +++ src/app/api/bots/[id]/post/route.ts | 170 + src/app/api/bots/[id]/reinstate/route.ts | 62 + src/app/api/bots/[id]/route.ts | 280 ++ .../bots/[id]/sources/[sid]/fetch/route.ts | 117 + src/app/api/bots/[id]/sources/[sid]/route.ts | 200 ++ src/app/api/bots/[id]/sources/route.ts | 239 ++ src/app/api/bots/[id]/suspend/route.ts | 74 + src/app/api/bots/route.ts | 189 ++ src/app/api/cron/bots/route.ts | 47 + src/app/api/media/preview/route.ts | 92 +- src/app/api/notifications/route.ts | 39 +- src/app/api/posts/[id]/like/route.ts | 3 +- src/app/api/posts/[id]/route.ts | 21 +- src/app/api/posts/route.ts | 18 +- src/app/api/search/route.ts | 2 + src/app/api/users/[handle]/followers/route.ts | 17 +- src/app/api/users/[handle]/following/route.ts | 17 +- src/app/api/users/[handle]/route.ts | 45 +- src/app/api/users/route.ts | 1 + src/app/explore/page.tsx | 42 +- src/app/globals.css | 1 + src/app/search/page.tsx | 24 +- src/app/settings/bots/[id]/edit/page.tsx | 1026 ++++++ src/app/settings/bots/[id]/page.tsx | 730 +++++ src/app/settings/bots/new/page.tsx | 950 ++++++ src/app/settings/bots/page.tsx | 187 ++ src/app/settings/page.tsx | 18 +- src/components/Icons.tsx | 11 + src/components/PostCard.tsx | 162 +- src/components/Sidebar.tsx | 8 +- src/db/schema.ts | 259 +- src/lib/activitypub/actor.ts | 69 +- src/lib/bots/activityLogger.ts | 290 ++ src/lib/bots/autonomous.property.test.ts | 682 ++++ src/lib/bots/autonomous.ts | 677 ++++ src/lib/bots/botManager.property.test.ts | 2064 ++++++++++++ src/lib/bots/botManager.test.ts | 302 ++ src/lib/bots/botManager.ts | 899 +++++ src/lib/bots/contentFetcher.property.test.ts | 913 ++++++ src/lib/bots/contentFetcher.test.ts | 276 ++ src/lib/bots/contentFetcher.ts | 991 ++++++ .../bots/contentGenerator.property.test.ts | 2182 +++++++++++++ src/lib/bots/contentGenerator.test.ts | 816 +++++ src/lib/bots/contentGenerator.ts | 655 ++++ src/lib/bots/contentSource.property.test.ts | 924 ++++++ src/lib/bots/contentSource.test.ts | 445 +++ src/lib/bots/contentSource.ts | 861 +++++ src/lib/bots/encryption.test.ts | 485 +++ src/lib/bots/encryption.ts | 343 ++ src/lib/bots/llmClient.test.ts | 1305 ++++++++ src/lib/bots/llmClient.ts | 662 ++++ src/lib/bots/mentionHandler.property.test.ts | 821 +++++ src/lib/bots/mentionHandler.ts | 636 ++++ src/lib/bots/personality.property.test.ts | 439 +++ src/lib/bots/personality.test.ts | 464 +++ src/lib/bots/personality.ts | 637 ++++ src/lib/bots/posting.property.test.ts | 534 +++ src/lib/bots/posting.ts | 1210 +++++++ src/lib/bots/rateLimiter.property.test.ts | 905 +++++ src/lib/bots/rateLimiter.test.ts | 434 +++ src/lib/bots/rateLimiter.ts | 388 +++ src/lib/bots/rssParser.property.test.ts | 649 ++++ src/lib/bots/rssParser.test.ts | 648 ++++ src/lib/bots/rssParser.ts | 694 ++++ src/lib/bots/sanitization.ts | 275 ++ src/lib/bots/scheduler.property.test.ts | 2251 +++++++++++++ src/lib/bots/scheduler.test.ts | 871 +++++ src/lib/bots/scheduler.ts | 944 ++++++ src/lib/bots/suspension.ts | 96 + src/lib/types.ts | 13 + vitest.config.ts | 18 + 102 files changed, 41692 insertions(+), 164 deletions(-) create mode 100644 .kiro/specs/bot-system/IMPROVEMENTS.md create mode 100644 .kiro/specs/bot-system/design.md create mode 100644 .kiro/specs/bot-system/requirements.md create mode 100644 .kiro/specs/bot-system/tasks.md create mode 100644 .vscode/settings.json create mode 100644 bot-cron.ts create mode 100644 clear_bots.ts create mode 100644 drizzle/0000_clumsy_donald_blake.sql create mode 100644 drizzle/0001_remove_fetch_interval.sql create mode 100644 drizzle/0002_add_bot_id_to_posts.sql create mode 100644 drizzle/0003_fix_bot_content_items_fk.sql create mode 100644 drizzle/0004_add_source_config.sql create mode 100644 drizzle/0005_bots_as_users.sql create mode 100644 drizzle/meta/0000_snapshot.json create mode 100644 drizzle/meta/_journal.json create mode 100644 inspect_bots.ts create mode 100644 src/app/api/bots/[id]/api-key/route.ts create mode 100644 src/app/api/bots/[id]/api-key/status/route.ts create mode 100644 src/app/api/bots/[id]/logs/errors/route.ts create mode 100644 src/app/api/bots/[id]/logs/route.ts create mode 100644 src/app/api/bots/[id]/mentions/[mid]/respond/route.ts create mode 100644 src/app/api/bots/[id]/mentions/route.ts create mode 100644 src/app/api/bots/[id]/post/route.test.ts create mode 100644 src/app/api/bots/[id]/post/route.ts create mode 100644 src/app/api/bots/[id]/reinstate/route.ts create mode 100644 src/app/api/bots/[id]/route.ts create mode 100644 src/app/api/bots/[id]/sources/[sid]/fetch/route.ts create mode 100644 src/app/api/bots/[id]/sources/[sid]/route.ts create mode 100644 src/app/api/bots/[id]/sources/route.ts create mode 100644 src/app/api/bots/[id]/suspend/route.ts create mode 100644 src/app/api/bots/route.ts create mode 100644 src/app/api/cron/bots/route.ts create mode 100644 src/app/settings/bots/[id]/edit/page.tsx create mode 100644 src/app/settings/bots/[id]/page.tsx create mode 100644 src/app/settings/bots/new/page.tsx create mode 100644 src/app/settings/bots/page.tsx create mode 100644 src/lib/bots/activityLogger.ts create mode 100644 src/lib/bots/autonomous.property.test.ts create mode 100644 src/lib/bots/autonomous.ts create mode 100644 src/lib/bots/botManager.property.test.ts create mode 100644 src/lib/bots/botManager.test.ts create mode 100644 src/lib/bots/botManager.ts create mode 100644 src/lib/bots/contentFetcher.property.test.ts create mode 100644 src/lib/bots/contentFetcher.test.ts create mode 100644 src/lib/bots/contentFetcher.ts create mode 100644 src/lib/bots/contentGenerator.property.test.ts create mode 100644 src/lib/bots/contentGenerator.test.ts create mode 100644 src/lib/bots/contentGenerator.ts create mode 100644 src/lib/bots/contentSource.property.test.ts create mode 100644 src/lib/bots/contentSource.test.ts create mode 100644 src/lib/bots/contentSource.ts create mode 100644 src/lib/bots/encryption.test.ts create mode 100644 src/lib/bots/encryption.ts create mode 100644 src/lib/bots/llmClient.test.ts create mode 100644 src/lib/bots/llmClient.ts create mode 100644 src/lib/bots/mentionHandler.property.test.ts create mode 100644 src/lib/bots/mentionHandler.ts create mode 100644 src/lib/bots/personality.property.test.ts create mode 100644 src/lib/bots/personality.test.ts create mode 100644 src/lib/bots/personality.ts create mode 100644 src/lib/bots/posting.property.test.ts create mode 100644 src/lib/bots/posting.ts create mode 100644 src/lib/bots/rateLimiter.property.test.ts create mode 100644 src/lib/bots/rateLimiter.test.ts create mode 100644 src/lib/bots/rateLimiter.ts create mode 100644 src/lib/bots/rssParser.property.test.ts create mode 100644 src/lib/bots/rssParser.test.ts create mode 100644 src/lib/bots/rssParser.ts create mode 100644 src/lib/bots/sanitization.ts create mode 100644 src/lib/bots/scheduler.property.test.ts create mode 100644 src/lib/bots/scheduler.test.ts create mode 100644 src/lib/bots/scheduler.ts create mode 100644 src/lib/bots/suspension.ts create mode 100644 vitest.config.ts diff --git a/.kiro/specs/bot-system/IMPROVEMENTS.md b/.kiro/specs/bot-system/IMPROVEMENTS.md new file mode 100644 index 0000000..b5657e7 --- /dev/null +++ b/.kiro/specs/bot-system/IMPROVEMENTS.md @@ -0,0 +1,119 @@ +# Bot System Improvements + +## Recent Changes + +### 1. Simplified Encryption (✅ Completed) + +**Problem**: Required a separate `BOT_ENCRYPTION_KEY` environment variable, adding complexity to deployment. + +**Solution**: Now uses `AUTH_SECRET` (which is already required) for encryption. A 32-byte key is derived using SHA-256. + +**Benefits**: +- One less environment variable to manage +- Simpler deployment process +- Same security level (AES-256-GCM) + +**Migration**: Remove `BOT_ENCRYPTION_KEY` from your `.env` file. Existing encrypted keys will need to be re-encrypted (or you can keep the old key temporarily for migration). + +### 2. Automatic Content Fetching (✅ Completed) + +**Problem**: Users had to manually set fetch intervals, and content fetching was a separate scheduled process. + +**Solution**: Content is now fetched automatically when a post is triggered. The system: +1. Checks all active content sources +2. Fetches fresh content (with retry logic) +3. Selects the best content item +4. Generates and posts + +**Benefits**: +- No manual fetch interval configuration needed +- Always uses fresh content +- Simpler user experience +- Removed `fetchIntervalMinutes` column from database + +**Migration**: Run the migration: `drizzle/0001_remove_fetch_interval.sql` + +### 3. Fixed Reddit Link Previews (✅ Completed) + +**Problem**: Reddit blocks regular HTTP scraping with aggressive bot detection, returning login/blocked pages instead of actual content with Open Graph tags. This caused bot posts with Reddit URLs to have NULL metadata for title, description, and image. + +**Solution**: Implemented Reddit-specific handling using their oEmbed API (`https://www.reddit.com/oembed`), which is designed for embedding and doesn't require authentication. + +**Benefits**: +- Reddit link previews now work reliably +- Extracts title, description (subreddit/author), and thumbnail when available +- Falls back gracefully if oEmbed fails +- Other sites continue to use standard OG tag scraping + +**Files Modified**: +- `src/lib/bots/posting.ts` - Added `isRedditUrl()` and `fetchRedditPreview()` functions +- `src/app/api/media/preview/route.ts` - Same Reddit-specific handling for the preview API + +## Implementation Details + +### Encryption Changes + +**File**: `src/lib/bots/encryption.ts` + +```typescript +// Before +function getEncryptionKey(): Buffer { + const keyEnv = process.env.BOT_ENCRYPTION_KEY; + // ... complex base64/hex decoding logic +} + +// After +function getEncryptionKey(): Buffer { + const keyEnv = process.env.AUTH_SECRET; + if (!keyEnv) { + throw new Error('AUTH_SECRET environment variable is not set'); + } + // Create a 32-byte key from AUTH_SECRET using SHA-256 + return crypto.createHash('sha256').update(keyEnv).digest(); +} +``` + +### Content Fetching Changes + +**File**: `src/lib/bots/posting.ts` + +The `triggerPost` function now automatically fetches content before posting: + +```typescript +// Auto-fetch content from sources before posting +const activeSources = await getActiveSourcesByBot(botId); +if (activeSources.length > 0) { + const fetchResults = await Promise.allSettled( + activeSources.map(source => + fetchContentWithRetry(source.id, 2, { maxItems: 10, timeout: 15000 }) + ) + ); +} +``` + +### Database Schema Changes + +**File**: `src/db/schema.ts` + +Removed: +```typescript +fetchIntervalMinutes: integer('fetch_interval_minutes').default(30).notNull(), +``` + +## Testing + +After these changes, test: + +1. ✅ Bot creation with API key encryption +2. ✅ Manual post triggering (should auto-fetch content) +3. ✅ Reddit content sources +4. ✅ RSS content sources +5. ✅ News API content sources + +## Next Steps + +Consider: +- Add UI feedback showing when content is being fetched +- Add content preview before posting +- Add ability to regenerate post content if user doesn't like it +- Add scheduling for automatic posts (cron-based) diff --git a/.kiro/specs/bot-system/design.md b/.kiro/specs/bot-system/design.md new file mode 100644 index 0000000..c916d6d --- /dev/null +++ b/.kiro/specs/bot-system/design.md @@ -0,0 +1,833 @@ +# Design Document: Bot System + +## Overview + +This document describes the technical design for implementing an autonomous bot system in Synapsis. The system enables users to create bots that monitor external content sources (RSS, Reddit, news APIs), generate posts using LLM APIs, and respond to mentions. Bots operate as special user entities with clear identification and configurable personalities. + +The design integrates with existing Synapsis infrastructure: +- Drizzle ORM for database operations +- Next.js API routes for endpoints +- ActivityPub federation for cross-instance communication +- Existing authentication and user systems + +## Architecture + +```mermaid +graph TB + subgraph "User Interface" + UI[Bot Management UI] + end + + subgraph "API Layer" + BA[Bot API Routes] + CA[Content Source API] + LA[Activity Log API] + end + + subgraph "Core Services" + BM[Bot Manager] + CS[Content Source Monitor] + PS[Post Scheduler] + MH[Mention Handler] + CG[Content Generator] + RL[Rate Limiter] + AL[Activity Logger] + end + + subgraph "External Services" + LLM[LLM Providers] + RSS[RSS Feeds] + RD[Reddit API] + NEWS[News APIs] + end + + subgraph "Data Layer" + DB[(PostgreSQL)] + CACHE[Redis Cache] + end + + subgraph "Federation" + AP[ActivityPub] + end + + UI --> BA + BA --> BM + BA --> CS + BA --> LA + + BM --> DB + BM --> RL + BM --> AL + BM --> CG + + CS --> RSS + CS --> RD + CS --> NEWS + CS --> DB + + CG --> LLM + + PS --> BM + MH --> BM + MH --> CG + + BM --> AP +``` + +### Component Responsibilities + +1. **Bot Manager**: Core orchestrator for bot lifecycle, configuration, and operations +2. **Content Source Monitor**: Fetches and parses external content from RSS, Reddit, and news APIs +3. **Post Scheduler**: Manages scheduled and autonomous posting based on configuration +4. **Mention Handler**: Detects mentions and triggers response generation +5. **Content Generator**: Interfaces with LLM providers to generate posts and replies +6. **Rate Limiter**: Enforces posting limits and prevents abuse +7. **Activity Logger**: Records all bot actions for auditing and debugging + +## Components and Interfaces + +### Bot Manager Service + +```typescript +interface BotManager { + // Bot CRUD + createBot(userId: string, config: BotCreateInput): Promise; + updateBot(botId: string, config: BotUpdateInput): Promise; + deleteBot(botId: string): Promise; + getBotsByUser(userId: string): Promise; + getBotById(botId: string): Promise; + + // Bot Operations + triggerPost(botId: string, sourceContentId?: string): Promise; + processScheduledPosts(): Promise; + suspendBot(botId: string, reason: string): Promise; + reinstateBot(botId: string): Promise; +} + +interface BotCreateInput { + name: string; + handle: string; + bio?: string; + avatarUrl?: string; + personality: PersonalityConfig; + llmConfig: LLMConfig; + contentSources?: ContentSourceConfig[]; + schedule?: ScheduleConfig; + autonomousMode?: boolean; +} + +interface PersonalityConfig { + systemPrompt: string; + temperature: number; + maxTokens: number; + responseStyle?: string; +} + +interface LLMConfig { + provider: 'openrouter' | 'openai' | 'anthropic'; + apiKey: string; // Encrypted before storage + model: string; +} +``` + +### Content Source Monitor + +```typescript +interface ContentSourceMonitor { + addSource(botId: string, config: ContentSourceConfig): Promise; + removeSource(sourceId: string): Promise; + fetchContent(sourceId: string): Promise; + processAllSources(): Promise; +} + +interface ContentSourceConfig { + type: 'rss' | 'reddit' | 'news_api'; + url: string; + subreddit?: string; // For Reddit + apiKey?: string; // For news APIs + fetchInterval: number; // Minutes + keywords?: string[]; // Optional filtering +} + +interface ContentItem { + id: string; + sourceId: string; + title: string; + content: string; + url: string; + publishedAt: Date; + processed: boolean; +} +``` + +### Content Generator + +```typescript +interface ContentGenerator { + generatePost( + bot: Bot, + sourceContent?: ContentItem, + context?: string + ): Promise; + + generateReply( + bot: Bot, + mentionPost: Post, + conversationContext: Post[] + ): Promise; + + evaluateContentInterest( + bot: Bot, + content: ContentItem + ): Promise<{ interesting: boolean; reason: string }>; +} + +interface GeneratedContent { + text: string; + tokensUsed: number; + model: string; +} +``` + +### Mention Handler + +```typescript +interface MentionHandler { + detectMentions(botId: string): Promise; + processMention(mentionId: string): Promise; + getUnprocessedMentions(botId: string): Promise; +} + +interface Mention { + id: string; + botId: string; + postId: string; + authorId: string; + content: string; + createdAt: Date; + processedAt?: Date; + responsePostId?: string; +} +``` + +### Rate Limiter + +```typescript +interface RateLimiter { + canPost(botId: string): Promise<{ allowed: boolean; reason?: string }>; + recordPost(botId: string): Promise; + getPostCount(botId: string, windowHours: number): Promise; + getRemainingQuota(botId: string): Promise<{ daily: number; hourly: number }>; +} + +// Rate limit constants +const RATE_LIMITS = { + MAX_POSTS_PER_DAY: 50, + MIN_POST_INTERVAL_MINUTES: 5, + MAX_REPLIES_PER_HOUR: 20, +}; +``` + +### Activity Logger + +```typescript +interface ActivityLogger { + log(entry: ActivityLogEntry): Promise; + getLogsForBot(botId: string, options: LogQueryOptions): Promise; + getErrorLogs(botId: string): Promise; +} + +interface ActivityLogEntry { + botId: string; + action: 'post_created' | 'mention_response' | 'content_fetched' | + 'llm_call' | 'error' | 'config_changed' | 'rate_limited'; + details: Record; + success: boolean; + errorMessage?: string; +} + +interface LogQueryOptions { + actionTypes?: string[]; + startDate?: Date; + endDate?: Date; + limit?: number; + offset?: number; +} +``` + +## Data Models + +### Database Schema Additions + +```typescript +// New tables for bot system + +export const bots = pgTable('bots', { + id: uuid('id').primaryKey().defaultRandom(), + userId: uuid('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }), + name: text('name').notNull(), + handle: text('handle').notNull().unique(), + bio: text('bio'), + avatarUrl: text('avatar_url'), + + // Personality configuration (JSON) + personalityConfig: text('personality_config').notNull(), // JSON + + // LLM configuration + llmProvider: text('llm_provider').notNull(), // openrouter, openai, anthropic + llmModel: text('llm_model').notNull(), + llmApiKeyEncrypted: text('llm_api_key_encrypted').notNull(), + + // Scheduling + scheduleConfig: text('schedule_config'), // JSON + autonomousMode: boolean('autonomous_mode').default(false).notNull(), + + // Status + isActive: boolean('is_active').default(true).notNull(), + isSuspended: boolean('is_suspended').default(false).notNull(), + suspensionReason: text('suspension_reason'), + suspendedAt: timestamp('suspended_at'), + + // ActivityPub keys + publicKey: text('public_key').notNull(), + privateKeyEncrypted: text('private_key_encrypted').notNull(), + + // Timestamps + lastPostAt: timestamp('last_post_at'), + createdAt: timestamp('created_at').defaultNow().notNull(), + updatedAt: timestamp('updated_at').defaultNow().notNull(), +}, (table) => [ + index('bots_user_id_idx').on(table.userId), + index('bots_handle_idx').on(table.handle), + index('bots_active_idx').on(table.isActive), +]); + +export const botContentSources = pgTable('bot_content_sources', { + id: uuid('id').primaryKey().defaultRandom(), + botId: uuid('bot_id').notNull().references(() => bots.id, { onDelete: 'cascade' }), + + type: text('type').notNull(), // rss, reddit, news_api + url: text('url').notNull(), + subreddit: text('subreddit'), // For Reddit sources + apiKeyEncrypted: text('api_key_encrypted'), // For news APIs + + fetchIntervalMinutes: integer('fetch_interval_minutes').default(30).notNull(), + keywords: text('keywords'), // JSON array for filtering + + isActive: boolean('is_active').default(true).notNull(), + lastFetchAt: timestamp('last_fetch_at'), + lastError: text('last_error'), + consecutiveErrors: integer('consecutive_errors').default(0).notNull(), + + createdAt: timestamp('created_at').defaultNow().notNull(), + updatedAt: timestamp('updated_at').defaultNow().notNull(), +}, (table) => [ + index('bot_content_sources_bot_idx').on(table.botId), + index('bot_content_sources_type_idx').on(table.type), +]); + +export const botContentItems = pgTable('bot_content_items', { + id: uuid('id').primaryKey().defaultRandom(), + sourceId: uuid('source_id').notNull().references(() => botContentSources.id, { onDelete: 'cascade' }), + + externalId: text('external_id').notNull(), // Unique ID from source + title: text('title').notNull(), + content: text('content'), + url: text('url').notNull(), + + publishedAt: timestamp('published_at').notNull(), + fetchedAt: timestamp('fetched_at').defaultNow().notNull(), + + isProcessed: boolean('is_processed').default(false).notNull(), + processedAt: timestamp('processed_at'), + postId: uuid('post_id').references(() => posts.id), // If a post was created + + interestScore: integer('interest_score'), // LLM evaluation score + interestReason: text('interest_reason'), +}, (table) => [ + index('bot_content_items_source_idx').on(table.sourceId), + index('bot_content_items_processed_idx').on(table.isProcessed), + index('bot_content_items_external_idx').on(table.externalId), +]); + +export const botMentions = pgTable('bot_mentions', { + id: uuid('id').primaryKey().defaultRandom(), + botId: uuid('bot_id').notNull().references(() => bots.id, { onDelete: 'cascade' }), + postId: uuid('post_id').notNull().references(() => posts.id, { onDelete: 'cascade' }), + + authorId: uuid('author_id').notNull().references(() => users.id), + content: text('content').notNull(), + + isProcessed: boolean('is_processed').default(false).notNull(), + processedAt: timestamp('processed_at'), + responsePostId: uuid('response_post_id').references(() => posts.id), + + // For federated mentions + isRemote: boolean('is_remote').default(false).notNull(), + remoteActorUrl: text('remote_actor_url'), + + createdAt: timestamp('created_at').defaultNow().notNull(), +}, (table) => [ + index('bot_mentions_bot_idx').on(table.botId), + index('bot_mentions_processed_idx').on(table.isProcessed), + index('bot_mentions_created_idx').on(table.createdAt), +]); + +export const botActivityLogs = pgTable('bot_activity_logs', { + id: uuid('id').primaryKey().defaultRandom(), + botId: uuid('bot_id').notNull().references(() => bots.id, { onDelete: 'cascade' }), + + action: text('action').notNull(), // post_created, mention_response, etc. + details: text('details').notNull(), // JSON + + success: boolean('success').notNull(), + errorMessage: text('error_message'), + + createdAt: timestamp('created_at').defaultNow().notNull(), +}, (table) => [ + index('bot_activity_logs_bot_idx').on(table.botId), + index('bot_activity_logs_action_idx').on(table.action), + index('bot_activity_logs_created_idx').on(table.createdAt), +]); + +export const botRateLimits = pgTable('bot_rate_limits', { + id: uuid('id').primaryKey().defaultRandom(), + botId: uuid('bot_id').notNull().references(() => bots.id, { onDelete: 'cascade' }), + + windowStart: timestamp('window_start').notNull(), + windowType: text('window_type').notNull(), // daily, hourly + postCount: integer('post_count').default(0).notNull(), + replyCount: integer('reply_count').default(0).notNull(), + + createdAt: timestamp('created_at').defaultNow().notNull(), +}, (table) => [ + index('bot_rate_limits_bot_window_idx').on(table.botId, table.windowStart), +]); +``` + +### TypeScript Types + +```typescript +// Bot types +export interface Bot { + id: string; + userId: string; + name: string; + handle: string; + bio?: string; + avatarUrl?: string; + personalityConfig: PersonalityConfig; + llmProvider: 'openrouter' | 'openai' | 'anthropic'; + llmModel: string; + scheduleConfig?: ScheduleConfig; + autonomousMode: boolean; + isActive: boolean; + isSuspended: boolean; + suspensionReason?: string; + lastPostAt?: Date; + createdAt: Date; + updatedAt: Date; +} + +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 ContentSource { + id: string; + botId: string; + type: 'rss' | 'reddit' | 'news_api'; + url: string; + subreddit?: string; + fetchIntervalMinutes: number; + keywords?: string[]; + isActive: boolean; + lastFetchAt?: Date; + lastError?: string; +} +``` + +## API Endpoints + +### Bot Management + +``` +POST /api/bots - Create a new bot +GET /api/bots - List user's bots +GET /api/bots/:id - Get bot details +PUT /api/bots/:id - Update bot configuration +DELETE /api/bots/:id - Delete a bot +POST /api/bots/:id/suspend - Suspend a bot (admin) +POST /api/bots/:id/reinstate - Reinstate a bot (admin) +``` + +### API Key Management + +``` +POST /api/bots/:id/api-key - Set/update LLM API key +DELETE /api/bots/:id/api-key - Remove API key +GET /api/bots/:id/api-key/status - Check if API key is configured +``` + +### Content Sources + +``` +POST /api/bots/:id/sources - Add content source +GET /api/bots/:id/sources - List content sources +PUT /api/bots/:id/sources/:sid - Update content source +DELETE /api/bots/:id/sources/:sid - Remove content source +POST /api/bots/:id/sources/:sid/fetch - Manually trigger fetch +``` + +### Bot Operations + +``` +POST /api/bots/:id/post - Manually trigger a post +GET /api/bots/:id/mentions - Get pending mentions +POST /api/bots/:id/mentions/:mid/respond - Manually respond to mention +``` + +### Activity Logs + +``` +GET /api/bots/:id/logs - Get activity logs +GET /api/bots/:id/logs/errors - Get error logs only +``` + +### Public Bot Endpoints (for federation) + +``` +GET /api/users/:handle - Returns bot actor (with bot flag) +GET /api/users/:handle/outbox - Bot's outbox +POST /api/users/:handle/inbox - Bot's inbox (for mentions) +``` + + + +## Correctness Properties + +*A property is a characteristic or behavior that should hold true across all valid executions of a system—essentially, a formal statement about what the system should do. Properties serve as the bridge between human-readable specifications and machine-verifiable correctness guarantees.* + +### Property 1: Bot Creation Links to User + +*For any* valid bot configuration and user, when a bot is created, the resulting bot entity SHALL be linked to the creating user's account and have a unique identifier. + +**Validates: Requirements 1.1, 1.2** + +### Property 2: Bot Listing Completeness + +*For any* user with N bots, querying that user's bots SHALL return exactly N bots, all belonging to that user. + +**Validates: Requirements 1.3** + +### Property 3: Bot Deletion Cascade + +*For any* bot with associated content sources, content items, mentions, and activity logs, deleting the bot SHALL remove all associated data from the database. + +**Validates: Requirements 1.4** + +### Property 4: Bot Identification Immutability + +*For any* bot entity, the bot identification flag SHALL always be true and cannot be modified to false. + +**Validates: Requirements 1.5, 12.1, 12.2, 12.4** + +### Property 5: Bot Limit Enforcement + +*For any* user at the maximum bot limit, attempting to create an additional bot SHALL fail with an appropriate error. + +**Validates: Requirements 1.6** + +### Property 6: API Key Encryption Round-Trip + +*For any* valid API key, encrypting then decrypting the key SHALL produce the original key value, and the encrypted value SHALL differ from the original. + +**Validates: Requirements 2.2, 2.3** + +### Property 7: API Key Format Validation + +*For any* string that does not match valid API key formats for supported providers, the validation SHALL reject the key. + +**Validates: Requirements 2.1** + +### Property 8: LLM Provider Support + +*For any* of the supported LLM providers (OpenRouter, OpenAI, Anthropic), creating a bot with that provider SHALL succeed. + +**Validates: Requirements 2.6** + +### Property 9: Personality Configuration Persistence + +*For any* valid personality configuration, storing then retrieving the configuration SHALL produce an equivalent configuration object. + +**Validates: Requirements 3.1, 3.3, 3.4** + +### Property 10: Personality in LLM Prompts + +*For any* bot with a configured personality, all LLM calls (posts and replies) SHALL include the personality system prompt in the request. + +**Validates: Requirements 3.2, 3.5** + +### Property 11: Content Source URL Validation + +*For any* invalid URL or unsupported source type, adding a content source SHALL fail with a validation error. + +**Validates: Requirements 4.1** + +### Property 12: RSS Parsing Correctness + +*For any* valid RSS feed XML, parsing SHALL extract all items with their titles, content, URLs, and publication dates. + +**Validates: Requirements 4.2** + +### Property 13: Content Item Storage + +*For any* fetched content item, the item SHALL be stored with its source reference and marked as unprocessed. + +**Validates: Requirements 4.5** + +### Property 14: Multiple Source Types Per Bot + +*For any* bot, adding content sources of different types (RSS, Reddit, news API) SHALL all succeed and be retrievable. + +**Validates: Requirements 4.6** + +### Property 15: Fetch Error Retry with Backoff + +*For any* content source that fails to fetch, consecutive failures SHALL increase the retry delay exponentially up to a maximum. + +**Validates: Requirements 4.7** + +### Property 16: Schedule Configuration Persistence + +*For any* valid schedule configuration (interval, times, or cron), storing then retrieving SHALL produce an equivalent configuration. + +**Validates: Requirements 5.1, 5.3** + +### Property 17: Scheduled Post Triggering + +*For any* bot with a due schedule and available content, the scheduler SHALL trigger post generation. + +**Validates: Requirements 5.2, 5.4** + +### Property 18: Skip When No Content + +*For any* bot with a due schedule but no unprocessed content, the scheduler SHALL skip the posting cycle without creating a post. + +**Validates: Requirements 5.5** + +### Property 19: Rate Limit Enforcement + +*For any* bot, posting more than 50 times per day OR posting within 5 minutes of the last post SHALL be rejected. + +**Validates: Requirements 5.6, 10.1, 10.2, 10.4** + +### Property 20: Autonomous Mode Content Evaluation + +*For any* bot in autonomous mode with new content, the system SHALL evaluate content interest before deciding to post. + +**Validates: Requirements 6.1, 6.2, 6.3** + +### Property 21: Autonomous Mode Toggle + +*For any* bot with autonomous mode disabled, the bot SHALL only create posts on schedule, not autonomously. + +**Validates: Requirements 6.5** + +### Property 22: Mention Detection + +*For any* post that mentions a bot's handle, the mention SHALL be detected and stored for processing. + +**Validates: Requirements 7.1, 7.2** + +### Property 23: Mention Response Context + +*For any* mention being processed, the LLM prompt SHALL include the original post content and conversation context. + +**Validates: Requirements 7.3, 7.4** + +### Property 24: Mention Chronological Processing + +*For any* bot with multiple unprocessed mentions, processing SHALL occur in chronological order (oldest first). + +**Validates: Requirements 7.5** + +### Property 25: Reply Rate Limiting + +*For any* bot, replying more than 20 times per hour SHALL be rejected. + +**Validates: Requirements 7.6** + +### Property 26: Activity Log Completeness + +*For any* bot action (post, reply, error, config change), an activity log entry SHALL be created with action type, timestamp, and result. + +**Validates: Requirements 8.1, 8.3, 8.4** + +### Property 27: Activity Log Ordering + +*For any* bot's activity logs, querying logs SHALL return them in reverse chronological order. + +**Validates: Requirements 8.2** + +### Property 28: Activity Log Filtering + +*For any* log query with action type or date range filters, only matching logs SHALL be returned. + +**Validates: Requirements 8.6** + +### Property 29: Federation Post Distribution + +*For any* post created by a bot, an ActivityPub Create activity SHALL be generated and queued for delivery to followers. + +**Validates: Requirements 9.1** + +### Property 30: Federated Mention Handling + +*For any* mention received via ActivityPub from a remote instance, the mention SHALL be detected and processed like local mentions. + +**Validates: Requirements 9.2** + +### Property 31: ActivityPub Bot Flag + +*For any* bot's ActivityPub actor representation, the actor object SHALL include `"type": "Service"` or a bot flag indicating automated status. + +**Validates: Requirements 9.3** + +### Property 32: Bot Follow Handling + +*For any* follow request to or from a bot, the federation protocol SHALL handle the relationship correctly (Accept/Reject activities). + +**Validates: Requirements 9.5** + +### Property 33: Input Sanitization + +*For any* user input containing potential injection attacks (SQL, XSS, command injection), the input SHALL be sanitized or rejected. + +**Validates: Requirements 10.5** + +### Property 34: Bot Suspension Enforcement + +*For any* suspended bot, all actions (posting, replying, fetching) SHALL be blocked until reinstated. + +**Validates: Requirements 10.6** + +### Property 35: LLM Prompt Construction + +*For any* post generation request, the LLM prompt SHALL combine source content with personality context and configured parameters. + +**Validates: Requirements 11.1, 11.2** + +### Property 36: Content Truncation + +*For any* source content exceeding the maximum length, the content SHALL be truncated or summarized before being sent to the LLM. + +**Validates: Requirements 11.3** + +### Property 37: LLM Retry Logic + +*For any* LLM call that fails, the system SHALL retry up to 3 times before logging an error. + +**Validates: Requirements 11.4** + +### Property 38: Post Content Validation + +*For any* generated post, the content SHALL be validated against platform requirements (length, format) before publishing. + +**Validates: Requirements 11.5** + +### Property 39: Bot Creator Attribution + +*For any* bot, the bot's data SHALL include a reference to the creating user that is retrievable. + +**Validates: Requirements 12.3** + +## Error Handling + +### API Errors + +| Error Code | Condition | Response | +|------------|-----------|----------| +| 400 | Invalid input (validation failure) | `{ error: "Invalid input", details: [...] }` | +| 401 | Not authenticated | `{ error: "Authentication required" }` | +| 403 | Not authorized (not bot owner) | `{ error: "Not authorized" }` | +| 403 | Bot suspended | `{ error: "Bot is suspended", reason: "..." }` | +| 404 | Bot/resource not found | `{ error: "Not found" }` | +| 429 | Rate limit exceeded | `{ error: "Rate limit exceeded", retryAfter: seconds }` | +| 500 | Internal error | `{ error: "Internal server error" }` | + +### Content Source Errors + +- **Network failures**: Log error, increment consecutive error count, schedule retry with exponential backoff +- **Parse failures**: Log error with content sample, mark source as errored +- **API rate limits**: Respect Retry-After header, adjust fetch interval + +### LLM Provider Errors + +- **Authentication failures**: Log error, notify user to check API key +- **Rate limits**: Implement exponential backoff, queue requests +- **Content policy violations**: Log violation, skip content item +- **Timeout**: Retry up to 3 times with increasing timeout + +### Federation Errors + +- **Delivery failures**: Queue for retry with exponential backoff +- **Invalid signatures**: Log and reject activity +- **Unknown actors**: Attempt to fetch actor, reject if unavailable + +## Testing Strategy + +### Unit Tests + +Unit tests focus on specific examples and edge cases: + +- Bot CRUD operations with valid/invalid inputs +- API key encryption/decryption edge cases +- RSS parsing with malformed feeds +- Schedule parsing for various formats +- Rate limit boundary conditions +- Input sanitization for injection attempts + +### Property-Based Tests + +Property-based tests validate universal properties using a library like `fast-check`: + +- **Minimum 100 iterations per property test** +- Each test references its design document property +- Tag format: `Feature: bot-system, Property N: [property title]` + +Example test structure: +```typescript +// Feature: bot-system, Property 6: API Key Encryption Round-Trip +test.prop([fc.string().filter(isValidApiKey)], (apiKey) => { + const encrypted = encryptApiKey(apiKey); + const decrypted = decryptApiKey(encrypted); + expect(decrypted).toBe(apiKey); + expect(encrypted).not.toBe(apiKey); +}); +``` + +### Integration Tests + +- Bot creation flow with database +- Content source fetching with mocked external APIs +- Mention detection and response flow +- Federation activity generation and delivery +- Rate limiting across multiple requests + +### Test Coverage Requirements + +- All 39 correctness properties must have corresponding property tests +- Critical paths (bot creation, posting, mentions) require integration tests +- Error handling paths require unit tests with specific error conditions diff --git a/.kiro/specs/bot-system/requirements.md b/.kiro/specs/bot-system/requirements.md new file mode 100644 index 0000000..41ade58 --- /dev/null +++ b/.kiro/specs/bot-system/requirements.md @@ -0,0 +1,174 @@ +# Requirements Document: Bot System + +## Introduction + +This document specifies the requirements for implementing an autonomous bot system in Synapsis. The system enables users to create and manage bots that can monitor external content sources, generate posts using LLM APIs, and interact with other users through mentions and replies. Bots operate under user accounts with configurable personalities and behaviors while maintaining clear identification as automated entities. + +## Glossary + +- **Bot**: An automated entity that operates under a user account, capable of posting content and responding to interactions +- **Bot_Creator**: A user who creates and manages one or more bots +- **Bot_Manager**: The system component responsible for bot lifecycle and operations +- **Content_Source**: An external data feed (RSS, Reddit, news API) that a bot monitors +- **LLM_Provider**: An external API service (e.g., OpenRouter) that generates text content +- **Personality_Configuration**: Settings that define a bot's voice, tone, and behavior patterns +- **Post_Scheduler**: The component that determines when bots should create posts +- **Mention_Handler**: The component that detects and processes mentions of bots +- **Activity_Logger**: The component that records bot actions and events +- **Federation_Protocol**: ActivityPub protocol used for inter-instance communication +- **Rate_Limiter**: The component that enforces posting frequency limits + +## Requirements + +### Requirement 1: Bot Creation and Management + +**User Story:** As a user, I want to create and manage bots under my account, so that I can deploy autonomous agents with specific purposes. + +#### Acceptance Criteria + +1. WHEN a user creates a bot, THE Bot_Manager SHALL create a new bot entity linked to the user's account +2. WHEN a bot is created, THE Bot_Manager SHALL assign it a unique identifier and mark it as a bot entity +3. WHEN a user views their bots, THE Bot_Manager SHALL display all bots associated with their account +4. WHEN a user deletes a bot, THE Bot_Manager SHALL remove the bot and all associated configurations +5. WHEN a bot is displayed, THE System SHALL clearly indicate that it is a bot and not a human user +6. THE Bot_Manager SHALL enforce a maximum limit of bots per user account + +### Requirement 2: API Key Configuration + +**User Story:** As a bot creator, I want to configure API keys for LLM providers, so that my bot can generate content. + +#### Acceptance Criteria + +1. WHEN a user provides an API key, THE Bot_Manager SHALL validate the key format before storage +2. WHEN storing API keys, THE Bot_Manager SHALL encrypt them using secure encryption +3. WHEN retrieving API keys for bot operations, THE Bot_Manager SHALL decrypt them securely +4. WHEN a user updates an API key, THE Bot_Manager SHALL replace the old key with the new encrypted key +5. WHEN a user deletes a bot, THE Bot_Manager SHALL remove all associated API keys from storage +6. THE Bot_Manager SHALL support multiple LLM provider types (OpenRouter, OpenAI, Anthropic) + +### Requirement 3: Personality and Behavior Configuration + +**User Story:** As a bot creator, I want to configure my bot's personality and behavior, so that it has a consistent voice and style. + +#### Acceptance Criteria + +1. WHEN a user configures a bot personality, THE Bot_Manager SHALL store the personality prompt and parameters +2. WHEN generating content, THE LLM_Provider SHALL use the configured personality prompt as context +3. WHEN a user updates personality settings, THE Bot_Manager SHALL apply changes to future bot actions +4. THE Personality_Configuration SHALL include system prompts, temperature, and other LLM parameters +5. WHEN a bot responds to mentions, THE LLM_Provider SHALL maintain consistency with the configured personality + +### Requirement 4: Content Source Monitoring + +**User Story:** As a bot creator, I want my bot to monitor external content sources, so that it can generate posts based on real-world information. + +#### Acceptance Criteria + +1. WHEN a user adds a content source, THE Bot_Manager SHALL validate the source URL and type +2. WHEN monitoring RSS feeds, THE Content_Source SHALL fetch and parse feed items at regular intervals +3. WHEN monitoring Reddit, THE Content_Source SHALL use Reddit API to retrieve posts from specified subreddits +4. WHEN monitoring news APIs, THE Content_Source SHALL fetch articles using the configured API endpoint +5. WHEN new content is detected, THE Content_Source SHALL store it for bot processing +6. THE Content_Source SHALL support multiple source types per bot (RSS, Reddit, news APIs) +7. WHEN a source fails to fetch, THE Content_Source SHALL log the error and retry with exponential backoff + +### Requirement 5: Scheduled Posting + +**User Story:** As a bot creator, I want to configure posting schedules, so that my bot posts at predictable intervals. + +#### Acceptance Criteria + +1. WHEN a user sets a posting schedule, THE Post_Scheduler SHALL store the interval configuration +2. WHEN the scheduled time arrives, THE Post_Scheduler SHALL trigger bot content generation +3. THE Post_Scheduler SHALL support multiple schedule types (fixed interval, time-of-day, cron-like) +4. WHEN generating scheduled posts, THE Bot_Manager SHALL select content from monitored sources +5. WHEN no new content is available, THE Post_Scheduler SHALL skip the posting cycle +6. THE Rate_Limiter SHALL enforce minimum intervals between posts to prevent spam + +### Requirement 6: Autonomous Posting + +**User Story:** As a bot creator, I want my bot to decide when to post based on content interest, so that it behaves more naturally. + +#### Acceptance Criteria + +1. WHEN autonomous mode is enabled, THE Bot_Manager SHALL evaluate content sources for posting opportunities +2. WHEN evaluating content, THE LLM_Provider SHALL determine if the content is interesting enough to post +3. WHEN the bot decides to post, THE Bot_Manager SHALL generate and publish the post +4. THE Rate_Limiter SHALL enforce maximum posting frequency for autonomous bots +5. WHEN autonomous posting is disabled, THE Bot_Manager SHALL only post on schedule + +### Requirement 7: Mention and Reply Detection + +**User Story:** As a bot creator, I want my bot to detect and respond to mentions, so that it can interact with other users. + +#### Acceptance Criteria + +1. WHEN a bot is mentioned in a post, THE Mention_Handler SHALL detect the mention within 5 minutes +2. WHEN a mention is detected, THE Mention_Handler SHALL retrieve the mentioning post content +3. WHEN processing a mention, THE LLM_Provider SHALL generate a contextually appropriate response +4. WHEN generating responses, THE Bot_Manager SHALL include the original post context in the LLM prompt +5. WHEN a bot receives multiple mentions, THE Mention_Handler SHALL process them in chronological order +6. THE Rate_Limiter SHALL enforce limits on reply frequency to prevent abuse + +### Requirement 8: Bot Activity Logging + +**User Story:** As a bot creator, I want to view my bot's activity history, so that I can monitor its behavior and troubleshoot issues. + +#### Acceptance Criteria + +1. WHEN a bot performs an action, THE Activity_Logger SHALL record the action type, timestamp, and result +2. WHEN a user views bot logs, THE Activity_Logger SHALL display actions in reverse chronological order +3. THE Activity_Logger SHALL record post creation, mention responses, errors, and configuration changes +4. WHEN an error occurs, THE Activity_Logger SHALL record the error message and context +5. THE Activity_Logger SHALL retain logs for a configurable retention period +6. WHEN viewing logs, THE System SHALL support filtering by action type and date range + +### Requirement 9: Federation Integration + +**User Story:** As a system architect, I want bots to integrate with ActivityPub federation, so that they work seamlessly across instances. + +#### Acceptance Criteria + +1. WHEN a bot creates a post, THE Federation_Protocol SHALL distribute it to followers on other instances +2. WHEN a bot is mentioned from another instance, THE Mention_Handler SHALL detect and process the mention +3. WHEN bot profiles are viewed from other instances, THE Federation_Protocol SHALL include bot identification metadata +4. THE Federation_Protocol SHALL mark bot actors with the "bot" flag in ActivityPub actor objects +5. WHEN a bot follows or is followed, THE Federation_Protocol SHALL handle the relationship correctly + +### Requirement 10: Security and Rate Limiting + +**User Story:** As a system administrator, I want bots to be rate-limited and secure, so that they cannot be abused or compromise the system. + +#### Acceptance Criteria + +1. THE Rate_Limiter SHALL enforce a maximum of 50 posts per bot per day +2. THE Rate_Limiter SHALL enforce a minimum interval of 5 minutes between posts +3. WHEN API keys are stored, THE Bot_Manager SHALL use AES-256 encryption +4. WHEN a bot exceeds rate limits, THE Rate_Limiter SHALL prevent further actions and log the violation +5. THE Bot_Manager SHALL validate all user inputs to prevent injection attacks +6. WHEN a bot is suspended for violations, THE Bot_Manager SHALL prevent all bot actions until reinstated + +### Requirement 11: Content Generation + +**User Story:** As a bot creator, I want my bot to generate engaging posts with commentary, so that it provides value to followers. + +#### Acceptance Criteria + +1. WHEN generating a post, THE LLM_Provider SHALL combine source content with personality context +2. WHEN creating commentary, THE LLM_Provider SHALL use the configured temperature and parameters +3. WHEN source content is too long, THE Bot_Manager SHALL truncate or summarize it before sending to LLM +4. WHEN LLM generation fails, THE Bot_Manager SHALL log the error and retry up to 3 times +5. WHEN a post is generated, THE Bot_Manager SHALL validate it meets platform content requirements +6. THE Bot_Manager SHALL support multiple content formats (text, links, quotes) + +### Requirement 12: Bot Identification and Transparency + +**User Story:** As a user, I want to clearly identify bots, so that I know when I'm interacting with automated entities. + +#### Acceptance Criteria + +1. WHEN displaying a bot profile, THE System SHALL show a "Bot" badge or indicator +2. WHEN a bot posts, THE System SHALL include bot identification in the post metadata +3. WHEN viewing bot details, THE System SHALL display the bot creator's username +4. THE System SHALL prevent bots from removing or hiding their bot status +5. WHEN federating bot profiles, THE Federation_Protocol SHALL include bot identification in ActivityPub objects diff --git a/.kiro/specs/bot-system/tasks.md b/.kiro/specs/bot-system/tasks.md new file mode 100644 index 0000000..f96b3a6 --- /dev/null +++ b/.kiro/specs/bot-system/tasks.md @@ -0,0 +1,428 @@ +# Implementation Plan: Bot System + +## Overview + +This implementation plan breaks down the bot system into incremental coding tasks. Each task builds on previous work and includes property-based tests to validate correctness. The plan follows the existing Synapsis patterns for database schema, API routes, and ActivityPub federation. + +## Tasks + +- [x] 1. Set up database schema for bot system + - [x] 1.1 Create bot tables in schema.ts + - Add `bots` table with user reference, personality config, LLM config, and status fields + - Add `botContentSources` table for RSS, Reddit, and news API sources + - Add `botContentItems` table for fetched content + - Add `botMentions` table for tracking mentions + - Add `botActivityLogs` table for activity logging + - Add `botRateLimits` table for rate limit tracking + - Add relations and indexes + - _Requirements: 1.1, 1.2, 4.5, 8.1_ + + - [x] 1.2 Run database migration + - Generate and apply Drizzle migration + - _Requirements: 1.1_ + +- [x] 2. Implement encryption utilities for API keys + - [x] 2.1 Create encryption module in src/lib/bots/encryption.ts + - Implement AES-256 encryption for API keys + - Implement decryption function + - Add key format validation for OpenRouter, OpenAI, Anthropic + - _Requirements: 2.1, 2.2, 2.3, 10.3_ + + - [x] 2.2 Write property test for API key encryption round-trip + - **Property 6: API Key Encryption Round-Trip** + - **Validates: Requirements 2.2, 2.3** + + - [x] 2.3 Write property test for API key format validation + - **Property 7: API Key Format Validation** + - **Validates: Requirements 2.1** + +- [x] 3. Implement Bot Manager core functionality + - [x] 3.1 Create Bot Manager service in src/lib/bots/botManager.ts + - Implement createBot with user linking and unique ID generation + - Implement updateBot for configuration changes + - Implement deleteBot with cascade deletion + - Implement getBotsByUser and getBotById + - Implement bot limit enforcement per user + - Generate ActivityPub keys for bots + - _Requirements: 1.1, 1.2, 1.3, 1.4, 1.5, 1.6_ + + - [x] 3.2 Write property test for bot creation links to user + - **Property 1: Bot Creation Links to User** + - **Validates: Requirements 1.1, 1.2** + + - [x] 3.3 Write property test for bot listing completeness + - **Property 2: Bot Listing Completeness** + - **Validates: Requirements 1.3** + + - [x] 3.4 Write property test for bot deletion cascade + - **Property 3: Bot Deletion Cascade** + - **Validates: Requirements 1.4** + + - [x] 3.5 Write property test for bot limit enforcement + - **Property 5: Bot Limit Enforcement** + - **Validates: Requirements 1.6** + +- [x] 4. Checkpoint - Ensure all tests pass + - Ensure all tests pass, ask the user if questions arise. + +- [x] 5. Implement Bot API routes + - [x] 5.1 Create bot CRUD API routes + - POST /api/bots - Create bot + - GET /api/bots - List user's bots + - GET /api/bots/[id] - Get bot details + - PUT /api/bots/[id] - Update bot + - DELETE /api/bots/[id] - Delete bot + - _Requirements: 1.1, 1.3, 1.4_ + + - [x] 5.2 Create API key management routes + - POST /api/bots/[id]/api-key - Set API key + - DELETE /api/bots/[id]/api-key - Remove API key + - GET /api/bots/[id]/api-key/status - Check key status + - _Requirements: 2.1, 2.2, 2.4_ + + - [x] 5.3 Write property test for LLM provider support + - **Property 8: LLM Provider Support** + - **Validates: Requirements 2.6** + +- [x] 6. Implement personality configuration + - [x] 6.1 Create personality config types and validation in src/lib/bots/personality.ts + - Define PersonalityConfig interface + - Implement validation for system prompt, temperature, maxTokens + - Implement storage and retrieval functions + - _Requirements: 3.1, 3.3, 3.4_ + + - [x] 6.2 Write property test for personality configuration persistence + - **Property 9: Personality Configuration Persistence** + - **Validates: Requirements 3.1, 3.3, 3.4** + +- [x] 7. Implement Content Source Monitor + - [x] 7.1 Create content source service in src/lib/bots/contentSource.ts + - Implement addSource with URL validation + - Implement removeSource + - Implement source type validation (RSS, Reddit, news API) + - _Requirements: 4.1, 4.6_ + + - [x] 7.2 Implement RSS feed parser + - Create RSS parser using xml parsing + - Extract title, content, URL, publication date + - Handle malformed feeds gracefully + - _Requirements: 4.2_ + + - [x] 7.3 Implement content fetching with retry logic + - Implement fetch with exponential backoff on failure + - Track consecutive errors per source + - Store fetched content items + - _Requirements: 4.5, 4.7_ + + - [x] 7.4 Write property test for content source URL validation + - **Property 11: Content Source URL Validation** + - **Validates: Requirements 4.1** + + - [x] 7.5 Write property test for RSS parsing correctness + - **Property 12: RSS Parsing Correctness** + - **Validates: Requirements 4.2** + + - [x] 7.6 Write property test for content item storage + - **Property 13: Content Item Storage** + - **Validates: Requirements 4.5** + + - [x] 7.7 Write property test for multiple source types per bot + - **Property 14: Multiple Source Types Per Bot** + - **Validates: Requirements 4.6** + + - [x] 7.8 Write property test for fetch error retry with backoff + - **Property 15: Fetch Error Retry with Backoff** + - **Validates: Requirements 4.7** + +- [x] 8. Implement content source API routes + - [x] 8.1 Create content source API routes + - POST /api/bots/[id]/sources - Add source + - GET /api/bots/[id]/sources - List sources + - PUT /api/bots/[id]/sources/[sid] - Update source + - DELETE /api/bots/[id]/sources/[sid] - Remove source + - POST /api/bots/[id]/sources/[sid]/fetch - Manual fetch + - _Requirements: 4.1, 4.6_ + +- [x] 9. Checkpoint - Ensure all tests pass + - Ensure all tests pass, ask the user if questions arise. + +- [x] 10. Implement Rate Limiter + - [x] 10.1 Create rate limiter service in src/lib/bots/rateLimiter.ts + - Implement canPost check (50/day, 5min interval) + - Implement canReply check (20/hour) + - Implement recordPost and recordReply + - Implement getRemainingQuota + - _Requirements: 5.6, 7.6, 10.1, 10.2, 10.4_ + + - [x] 10.2 Write property test for rate limit enforcement + - **Property 19: Rate Limit Enforcement** + - **Validates: Requirements 5.6, 10.1, 10.2, 10.4** + + - [x] 10.3 Write property test for reply rate limiting + - **Property 25: Reply Rate Limiting** + - **Validates: Requirements 7.6** + +- [x] 11. Implement Post Scheduler + - [x] 11.1 Create scheduler service in src/lib/bots/scheduler.ts + - Implement schedule configuration storage + - Support interval, time-of-day, and cron-like schedules + - Implement isDue check for schedules + - Implement processScheduledPosts + - Skip posting when no content available + - _Requirements: 5.1, 5.2, 5.3, 5.4, 5.5_ + + - [x] 11.2 Write property test for schedule configuration persistence + - **Property 16: Schedule Configuration Persistence** + - **Validates: Requirements 5.1, 5.3** + + - [x] 11.3 Write property test for scheduled post triggering + - **Property 17: Scheduled Post Triggering** + - **Validates: Requirements 5.2, 5.4** + + - [x] 11.4 Write property test for skip when no content + - **Property 18: Skip When No Content** + - **Validates: Requirements 5.5** + +- [x] 12. Implement Content Generator (LLM integration) + - [x] 12.1 Create LLM client in src/lib/bots/llmClient.ts + - Implement OpenRouter API client + - Implement OpenAI API client + - Implement Anthropic API client + - Add retry logic (3 retries) + - _Requirements: 2.6, 11.4_ + + - [x] 12.2 Create content generator in src/lib/bots/contentGenerator.ts + - Implement generatePost with personality context + - Implement generateReply with conversation context + - Implement evaluateContentInterest for autonomous mode + - Implement content truncation for long sources + - _Requirements: 3.2, 3.5, 6.2, 11.1, 11.2, 11.3_ + + - [x] 12.3 Write property test for personality in LLM prompts + - **Property 10: Personality in LLM Prompts** + - **Validates: Requirements 3.2, 3.5** + + - [x] 12.4 Write property test for LLM prompt construction + - **Property 35: LLM Prompt Construction** + - **Validates: Requirements 11.1, 11.2** + + - [x] 12.5 Write property test for content truncation + - **Property 36: Content Truncation** + - **Validates: Requirements 11.3** + + - [x] 12.6 Write property test for LLM retry logic + - **Property 37: LLM Retry Logic** + - **Validates: Requirements 11.4** + +- [x] 13. Checkpoint - Ensure all tests pass + - Ensure all tests pass, ask the user if questions arise. + +- [x] 14. Implement autonomous posting + - [x] 14.1 Create autonomous posting logic in src/lib/bots/autonomous.ts + - Implement content evaluation flow + - Implement autonomous post decision logic + - Respect rate limits + - Support autonomous mode toggle + - _Requirements: 6.1, 6.2, 6.3, 6.5_ + + - [x] 14.2 Write property test for autonomous mode content evaluation + - **Property 20: Autonomous Mode Content Evaluation** + - **Validates: Requirements 6.1, 6.2, 6.3** + + - [x] 14.3 Write property test for autonomous mode toggle + - **Property 21: Autonomous Mode Toggle** + - **Validates: Requirements 6.5** + +- [x] 15. Implement bot posting flow + - [x] 15.1 Create post creation logic in src/lib/bots/posting.ts + - Implement triggerPost function + - Select content from sources + - Generate post via LLM + - Validate post content + - Create post in database + - Integrate with rate limiter + - _Requirements: 5.4, 11.5, 11.6_ + + - [x] 15.2 Create bot operations API routes + - POST /api/bots/[id]/post - Manual post trigger + - _Requirements: 5.4_ + + - [x] 15.3 Write property test for post content validation + - **Property 38: Post Content Validation** + - **Validates: Requirements 11.5** + +- [x] 16. Implement Mention Handler + - [x] 16.1 Create mention detection in src/lib/bots/mentionHandler.ts + - Implement mention detection from posts + - Store detected mentions + - Retrieve mentioning post content + - _Requirements: 7.1, 7.2_ + + - [x] 16.2 Implement mention response generation + - Generate response with conversation context + - Include original post in LLM prompt + - Process mentions in chronological order + - Respect reply rate limits + - _Requirements: 7.3, 7.4, 7.5, 7.6_ + + - [x] 16.3 Create mention API routes + - GET /api/bots/[id]/mentions - Get pending mentions + - POST /api/bots/[id]/mentions/[mid]/respond - Manual respond + - _Requirements: 7.1_ + + - [x] 16.4 Write property test for mention detection + - **Property 22: Mention Detection** + - **Validates: Requirements 7.1, 7.2** + + - [x] 16.5 Write property test for mention response context + - **Property 23: Mention Response Context** + - **Validates: Requirements 7.3, 7.4** + + - [x] 16.6 Write property test for mention chronological processing + - **Property 24: Mention Chronological Processing** + - **Validates: Requirements 7.5** + +- [ ] 17. Checkpoint - Ensure all tests pass + - Ensure all tests pass, ask the user if questions arise. + +- [-] 18. Implement Activity Logger + - [x] 18.1 Create activity logger in src/lib/bots/activityLogger.ts + - Implement log function for all action types + - Record action type, timestamp, result, error message + - Implement getLogsForBot with filtering + - Implement reverse chronological ordering + - _Requirements: 8.1, 8.2, 8.3, 8.4, 8.6_ + + - [x] 18.2 Create activity log API routes + - GET /api/bots/[id]/logs - Get logs with filters + - GET /api/bots/[id]/logs/errors - Get error logs + - _Requirements: 8.2, 8.6_ + + - [ ] 18.3 Write property test for activity log completeness + - **Property 26: Activity Log Completeness** + - **Validates: Requirements 8.1, 8.3, 8.4** + + - [ ] 18.4 Write property test for activity log ordering + - **Property 27: Activity Log Ordering** + - **Validates: Requirements 8.2** + + - [ ] 18.5 Write property test for activity log filtering + - **Property 28: Activity Log Filtering** + - **Validates: Requirements 8.6** + +- [-] 19. Implement bot suspension + - [x] 19.1 Create suspension logic in src/lib/bots/suspension.ts + - Implement suspendBot function + - Implement reinstateBot function + - Block all actions for suspended bots + - _Requirements: 10.6_ + + - [x] 19.2 Create admin suspension API routes + - POST /api/bots/[id]/suspend - Suspend bot + - POST /api/bots/[id]/reinstate - Reinstate bot + - _Requirements: 10.6_ + + - [ ] 19.3 Write property test for bot suspension enforcement + - **Property 34: Bot Suspension Enforcement** + - **Validates: Requirements 10.6** + +- [-] 20. Implement input sanitization + - [x] 20.1 Create input sanitization in src/lib/bots/sanitization.ts + - Implement SQL injection prevention + - Implement XSS prevention + - Implement command injection prevention + - Apply to all user inputs + - _Requirements: 10.5_ + + - [ ] 20.2 Write property test for input sanitization + - **Property 33: Input Sanitization** + - **Validates: Requirements 10.5** + +- [ ] 21. Checkpoint - Ensure all tests pass + - Ensure all tests pass, ask the user if questions arise. + +- [-] 22. Implement ActivityPub federation for bots + - [-] 22.1 Update ActivityPub actor for bots in src/lib/activitypub/actor.ts + - Add bot type detection + - Include bot flag in actor object (type: "Service" or bot property) + - Include bot creator reference + - _Requirements: 9.3, 12.3, 12.4_ + + - [x] 22.2 Implement bot post federation + - Generate Create activities for bot posts + - Deliver to followers + - _Requirements: 9.1_ + + - [x] 22.3 Implement federated mention handling + - Detect mentions from remote instances + - Process remote mentions like local ones + - _Requirements: 9.2_ + + - [x] 22.4 Implement bot follow handling + - Handle Follow activities for bots + - Generate Accept/Reject activities + - _Requirements: 9.5_ + + - [ ] 22.5 Write property test for federation post distribution + - **Property 29: Federation Post Distribution** + - **Validates: Requirements 9.1** + + - [ ] 22.6 Write property test for federated mention handling + - **Property 30: Federated Mention Handling** + - **Validates: Requirements 9.2** + + - [ ] 22.7 Write property test for ActivityPub bot flag + - **Property 31: ActivityPub Bot Flag** + - **Validates: Requirements 9.3** + + - [ ] 22.8 Write property test for bot follow handling + - **Property 32: Bot Follow Handling** + - **Validates: Requirements 9.5** + +- [-] 23. Implement bot identification in UI + - [x] 23.1 Update user/bot display components + - Add bot badge to profile display + - Show bot creator on bot profiles + - Ensure bot flag cannot be hidden + - _Requirements: 12.1, 12.2, 12.3, 12.4_ + + - [ ] 23.2 Write property test for bot identification immutability + - **Property 4: Bot Identification Immutability** + - **Validates: Requirements 1.5, 12.1, 12.2, 12.4** + + - [ ] 23.3 Write property test for bot creator attribution + - **Property 39: Bot Creator Attribution** + - **Validates: Requirements 12.3** + +- [-] 24. Create Bot Management UI + - [x] 24.1 Create bot list page at src/app/settings/bots/page.tsx + - Display user's bots + - Show bot status, last post time + - Add create bot button + - _Requirements: 1.3_ + + - [x] 24.2 Create bot creation form at src/app/settings/bots/new/page.tsx + - Bot name, handle, bio, avatar + - Personality configuration + - LLM provider and model selection + - API key input + - _Requirements: 1.1, 2.1, 3.1_ + + - [x] 24.3 Create bot detail/edit page at src/app/settings/bots/[id]/page.tsx + - Edit bot configuration + - Manage content sources + - View activity logs + - Manual post/respond actions + - _Requirements: 1.3, 4.6, 8.2_ + +- [ ] 25. Final checkpoint - Ensure all tests pass + - Ensure all tests pass, ask the user if questions arise. + +## Notes + +- All tasks are required including property-based tests +- Each task references specific requirements for traceability +- Checkpoints ensure incremental validation +- Property tests validate universal correctness properties +- Unit tests validate specific examples and edge cases +- The implementation follows existing Synapsis patterns for consistency diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..eabd0c4 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "typescript.autoClosingTags": false +} \ No newline at end of file diff --git a/bot-cron.ts b/bot-cron.ts new file mode 100644 index 0000000..2567779 --- /dev/null +++ b/bot-cron.ts @@ -0,0 +1,63 @@ +/** + * Bot Cron Job Script + * + * Run with PM2: + * pm2 start bot-cron.ts --name "bot-cron" --cron "* * * * *" --no-autorestart + * + * Or for continuous running with internal interval: + * pm2 start bot-cron.ts --name "bot-cron" + */ + +const INTERVAL_MS = 60 * 1000; // 1 minute +const API_URL = process.env.NEXT_PUBLIC_NODE_DOMAIN + ? `https://${process.env.NEXT_PUBLIC_NODE_DOMAIN}/api/cron/bots` + : 'http://localhost:3000/api/cron/bots'; +const AUTH_SECRET = process.env.AUTH_SECRET || ''; + +async function runCron() { + const timestamp = new Date().toISOString(); + console.log(`[${timestamp}] Running bot cron job...`); + + try { + const headers: Record = { + 'Content-Type': 'application/json', + }; + + if (AUTH_SECRET) { + headers['Authorization'] = `Bearer ${AUTH_SECRET}`; + } + + const response = await fetch(API_URL, { + method: 'POST', + headers, + }); + + const data = await response.json(); + + if (response.ok) { + console.log(`[${timestamp}] Cron completed:`, JSON.stringify(data, null, 2)); + } else { + console.error(`[${timestamp}] Cron failed:`, data); + } + } catch (error) { + console.error(`[${timestamp}] Cron error:`, error); + } +} + +// Check if running with PM2 cron (single execution) or continuous mode +const isPM2Cron = process.env.PM2_CRON === 'true' || process.argv.includes('--once'); + +if (isPM2Cron) { + // Single execution mode (PM2 handles scheduling) + runCron().then(() => process.exit(0)); +} else { + // Continuous mode with internal interval + console.log(`Bot cron started. Running every ${INTERVAL_MS / 1000} seconds.`); + console.log(`API URL: ${API_URL}`); + + // Run immediately on start + runCron(); + + // Then run on interval + setInterval(runCron, INTERVAL_MS); +} diff --git a/clear_bots.ts b/clear_bots.ts new file mode 100644 index 0000000..1119125 --- /dev/null +++ b/clear_bots.ts @@ -0,0 +1,11 @@ + +import { db } from './src/db'; +import { bots } from './src/db/schema'; + +async function main() { + console.log('Deleting all bots...'); + await db.delete(bots); + console.log('Bots deleted.'); +} + +main().then(() => process.exit(0)).catch(console.error); diff --git a/drizzle/0000_clumsy_donald_blake.sql b/drizzle/0000_clumsy_donald_blake.sql new file mode 100644 index 0000000..50d7d0e --- /dev/null +++ b/drizzle/0000_clumsy_donald_blake.sql @@ -0,0 +1,369 @@ +CREATE TABLE "blocks" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "user_id" uuid NOT NULL, + "blocked_user_id" uuid NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "bot_activity_logs" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "bot_id" uuid NOT NULL, + "action" text NOT NULL, + "details" text NOT NULL, + "success" boolean NOT NULL, + "error_message" text, + "created_at" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "bot_content_items" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "source_id" uuid NOT NULL, + "external_id" text NOT NULL, + "title" text NOT NULL, + "content" text, + "url" text NOT NULL, + "published_at" timestamp NOT NULL, + "fetched_at" timestamp DEFAULT now() NOT NULL, + "is_processed" boolean DEFAULT false NOT NULL, + "processed_at" timestamp, + "post_id" uuid, + "interest_score" integer, + "interest_reason" text +); +--> statement-breakpoint +CREATE TABLE "bot_content_sources" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "bot_id" uuid NOT NULL, + "type" text NOT NULL, + "url" text NOT NULL, + "subreddit" text, + "api_key_encrypted" text, + "fetch_interval_minutes" integer DEFAULT 30 NOT NULL, + "keywords" text, + "is_active" boolean DEFAULT true NOT NULL, + "last_fetch_at" timestamp, + "last_error" text, + "consecutive_errors" integer DEFAULT 0 NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "bot_mentions" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "bot_id" uuid NOT NULL, + "post_id" uuid NOT NULL, + "author_id" uuid NOT NULL, + "content" text NOT NULL, + "is_processed" boolean DEFAULT false NOT NULL, + "processed_at" timestamp, + "response_post_id" uuid, + "is_remote" boolean DEFAULT false NOT NULL, + "remote_actor_url" text, + "created_at" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "bot_rate_limits" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "bot_id" uuid NOT NULL, + "window_start" timestamp NOT NULL, + "window_type" text NOT NULL, + "post_count" integer DEFAULT 0 NOT NULL, + "reply_count" integer DEFAULT 0 NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "bots" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "user_id" uuid NOT NULL, + "name" text NOT NULL, + "handle" text NOT NULL, + "bio" text, + "avatar_url" text, + "personality_config" text NOT NULL, + "llm_provider" text NOT NULL, + "llm_model" text NOT NULL, + "llm_api_key_encrypted" text NOT NULL, + "schedule_config" text, + "autonomous_mode" boolean DEFAULT false NOT NULL, + "is_active" boolean DEFAULT true NOT NULL, + "is_suspended" boolean DEFAULT false NOT NULL, + "suspension_reason" text, + "suspended_at" timestamp, + "public_key" text NOT NULL, + "private_key_encrypted" text NOT NULL, + "last_post_at" timestamp, + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL, + CONSTRAINT "bots_handle_unique" UNIQUE("handle") +); +--> statement-breakpoint +CREATE TABLE "follows" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "follower_id" uuid NOT NULL, + "following_id" uuid NOT NULL, + "ap_id" text, + "pending" boolean DEFAULT false, + "created_at" timestamp DEFAULT now() NOT NULL, + CONSTRAINT "follows_ap_id_unique" UNIQUE("ap_id") +); +--> statement-breakpoint +CREATE TABLE "handle_registry" ( + "handle" text PRIMARY KEY NOT NULL, + "did" text NOT NULL, + "node_domain" text NOT NULL, + "registered_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "likes" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "user_id" uuid NOT NULL, + "post_id" uuid NOT NULL, + "ap_id" text, + "created_at" timestamp DEFAULT now() NOT NULL, + CONSTRAINT "likes_ap_id_unique" UNIQUE("ap_id") +); +--> statement-breakpoint +CREATE TABLE "media" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "user_id" uuid NOT NULL, + "post_id" uuid, + "url" text NOT NULL, + "alt_text" text, + "mime_type" text, + "width" integer, + "height" integer, + "created_at" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "mutes" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "user_id" uuid NOT NULL, + "muted_user_id" uuid NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "nodes" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "domain" text NOT NULL, + "name" text NOT NULL, + "description" text, + "long_description" text, + "rules" text, + "banner_url" text, + "accent_color" text DEFAULT '#FFFFFF', + "public_key" text, + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL, + CONSTRAINT "nodes_domain_unique" UNIQUE("domain") +); +--> statement-breakpoint +CREATE TABLE "notifications" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "user_id" uuid NOT NULL, + "actor_id" uuid NOT NULL, + "post_id" uuid, + "type" text NOT NULL, + "read_at" timestamp, + "created_at" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "posts" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "user_id" uuid NOT NULL, + "content" text NOT NULL, + "reply_to_id" uuid, + "repost_of_id" uuid, + "likes_count" integer DEFAULT 0 NOT NULL, + "reposts_count" integer DEFAULT 0 NOT NULL, + "replies_count" integer DEFAULT 0 NOT NULL, + "is_removed" boolean DEFAULT false NOT NULL, + "removed_at" timestamp, + "removed_by" uuid, + "removed_reason" text, + "ap_id" text, + "ap_url" text, + "link_preview_url" text, + "link_preview_title" text, + "link_preview_description" text, + "link_preview_image" text, + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL, + CONSTRAINT "posts_ap_id_unique" UNIQUE("ap_id") +); +--> statement-breakpoint +CREATE TABLE "remote_followers" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "user_id" uuid NOT NULL, + "actor_url" text NOT NULL, + "inbox_url" text NOT NULL, + "shared_inbox_url" text, + "handle" text, + "activity_id" text, + "created_at" timestamp DEFAULT now() NOT NULL, + CONSTRAINT "remote_followers_actor_url_unique" UNIQUE("actor_url") +); +--> statement-breakpoint +CREATE TABLE "remote_follows" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "follower_id" uuid NOT NULL, + "target_handle" text NOT NULL, + "target_actor_url" text NOT NULL, + "inbox_url" text NOT NULL, + "activity_id" text NOT NULL, + "display_name" text, + "bio" text, + "avatar_url" text, + "created_at" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "remote_posts" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "ap_id" text NOT NULL, + "author_handle" text NOT NULL, + "author_actor_url" text NOT NULL, + "author_display_name" text, + "author_avatar_url" text, + "content" text NOT NULL, + "published_at" timestamp NOT NULL, + "link_preview_url" text, + "link_preview_title" text, + "link_preview_description" text, + "link_preview_image" text, + "media_json" text, + "fetched_at" timestamp DEFAULT now() NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL, + CONSTRAINT "remote_posts_ap_id_unique" UNIQUE("ap_id") +); +--> statement-breakpoint +CREATE TABLE "reports" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "reporter_id" uuid, + "target_type" text NOT NULL, + "target_id" uuid NOT NULL, + "reason" text NOT NULL, + "status" text DEFAULT 'open' NOT NULL, + "resolved_at" timestamp, + "resolved_by" uuid, + "resolution_note" text, + "created_at" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "sessions" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "user_id" uuid NOT NULL, + "token" text NOT NULL, + "expires_at" timestamp NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL, + CONSTRAINT "sessions_token_unique" UNIQUE("token") +); +--> statement-breakpoint +CREATE TABLE "users" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "did" text NOT NULL, + "handle" text NOT NULL, + "email" text, + "password_hash" text, + "display_name" text, + "bio" text, + "avatar_url" text, + "header_url" text, + "private_key_encrypted" text, + "public_key" text NOT NULL, + "node_id" uuid, + "is_suspended" boolean DEFAULT false NOT NULL, + "suspension_reason" text, + "suspended_at" timestamp, + "is_silenced" boolean DEFAULT false NOT NULL, + "silence_reason" text, + "silenced_at" timestamp, + "moved_to" text, + "moved_from" text, + "migrated_at" timestamp, + "followers_count" integer DEFAULT 0 NOT NULL, + "following_count" integer DEFAULT 0 NOT NULL, + "posts_count" integer DEFAULT 0 NOT NULL, + "website" text, + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL, + CONSTRAINT "users_did_unique" UNIQUE("did"), + CONSTRAINT "users_handle_unique" UNIQUE("handle"), + CONSTRAINT "users_email_unique" UNIQUE("email") +); +--> statement-breakpoint +ALTER TABLE "blocks" ADD CONSTRAINT "blocks_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "blocks" ADD CONSTRAINT "blocks_blocked_user_id_users_id_fk" FOREIGN KEY ("blocked_user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "bot_activity_logs" ADD CONSTRAINT "bot_activity_logs_bot_id_bots_id_fk" FOREIGN KEY ("bot_id") REFERENCES "public"."bots"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "bot_content_items" ADD CONSTRAINT "bot_content_items_source_id_bot_content_sources_id_fk" FOREIGN KEY ("source_id") REFERENCES "public"."bot_content_sources"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "bot_content_items" ADD CONSTRAINT "bot_content_items_post_id_posts_id_fk" FOREIGN KEY ("post_id") REFERENCES "public"."posts"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "bot_content_sources" ADD CONSTRAINT "bot_content_sources_bot_id_bots_id_fk" FOREIGN KEY ("bot_id") REFERENCES "public"."bots"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "bot_mentions" ADD CONSTRAINT "bot_mentions_bot_id_bots_id_fk" FOREIGN KEY ("bot_id") REFERENCES "public"."bots"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "bot_mentions" ADD CONSTRAINT "bot_mentions_post_id_posts_id_fk" FOREIGN KEY ("post_id") REFERENCES "public"."posts"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "bot_mentions" ADD CONSTRAINT "bot_mentions_author_id_users_id_fk" FOREIGN KEY ("author_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "bot_mentions" ADD CONSTRAINT "bot_mentions_response_post_id_posts_id_fk" FOREIGN KEY ("response_post_id") REFERENCES "public"."posts"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "bot_rate_limits" ADD CONSTRAINT "bot_rate_limits_bot_id_bots_id_fk" FOREIGN KEY ("bot_id") REFERENCES "public"."bots"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "bots" ADD CONSTRAINT "bots_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "follows" ADD CONSTRAINT "follows_follower_id_users_id_fk" FOREIGN KEY ("follower_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "follows" ADD CONSTRAINT "follows_following_id_users_id_fk" FOREIGN KEY ("following_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "likes" ADD CONSTRAINT "likes_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "likes" ADD CONSTRAINT "likes_post_id_posts_id_fk" FOREIGN KEY ("post_id") REFERENCES "public"."posts"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "media" ADD CONSTRAINT "media_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "media" ADD CONSTRAINT "media_post_id_posts_id_fk" FOREIGN KEY ("post_id") REFERENCES "public"."posts"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "mutes" ADD CONSTRAINT "mutes_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "mutes" ADD CONSTRAINT "mutes_muted_user_id_users_id_fk" FOREIGN KEY ("muted_user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "notifications" ADD CONSTRAINT "notifications_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "notifications" ADD CONSTRAINT "notifications_actor_id_users_id_fk" FOREIGN KEY ("actor_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "notifications" ADD CONSTRAINT "notifications_post_id_posts_id_fk" FOREIGN KEY ("post_id") REFERENCES "public"."posts"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "posts" ADD CONSTRAINT "posts_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "posts" ADD CONSTRAINT "posts_removed_by_users_id_fk" FOREIGN KEY ("removed_by") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "remote_followers" ADD CONSTRAINT "remote_followers_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "remote_follows" ADD CONSTRAINT "remote_follows_follower_id_users_id_fk" FOREIGN KEY ("follower_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "reports" ADD CONSTRAINT "reports_reporter_id_users_id_fk" FOREIGN KEY ("reporter_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "reports" ADD CONSTRAINT "reports_resolved_by_users_id_fk" FOREIGN KEY ("resolved_by") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "sessions" ADD CONSTRAINT "sessions_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "users" ADD CONSTRAINT "users_node_id_nodes_id_fk" FOREIGN KEY ("node_id") REFERENCES "public"."nodes"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "blocks_user_idx" ON "blocks" USING btree ("user_id");--> statement-breakpoint +CREATE INDEX "bot_activity_logs_bot_idx" ON "bot_activity_logs" USING btree ("bot_id");--> statement-breakpoint +CREATE INDEX "bot_activity_logs_action_idx" ON "bot_activity_logs" USING btree ("action");--> statement-breakpoint +CREATE INDEX "bot_activity_logs_created_idx" ON "bot_activity_logs" USING btree ("created_at");--> statement-breakpoint +CREATE INDEX "bot_content_items_source_idx" ON "bot_content_items" USING btree ("source_id");--> statement-breakpoint +CREATE INDEX "bot_content_items_processed_idx" ON "bot_content_items" USING btree ("is_processed");--> statement-breakpoint +CREATE INDEX "bot_content_items_external_idx" ON "bot_content_items" USING btree ("external_id");--> statement-breakpoint +CREATE INDEX "bot_content_sources_bot_idx" ON "bot_content_sources" USING btree ("bot_id");--> statement-breakpoint +CREATE INDEX "bot_content_sources_type_idx" ON "bot_content_sources" USING btree ("type");--> statement-breakpoint +CREATE INDEX "bot_mentions_bot_idx" ON "bot_mentions" USING btree ("bot_id");--> statement-breakpoint +CREATE INDEX "bot_mentions_processed_idx" ON "bot_mentions" USING btree ("is_processed");--> statement-breakpoint +CREATE INDEX "bot_mentions_created_idx" ON "bot_mentions" USING btree ("created_at");--> statement-breakpoint +CREATE INDEX "bot_rate_limits_bot_window_idx" ON "bot_rate_limits" USING btree ("bot_id","window_start");--> statement-breakpoint +CREATE INDEX "bots_user_id_idx" ON "bots" USING btree ("user_id");--> statement-breakpoint +CREATE INDEX "bots_handle_idx" ON "bots" USING btree ("handle");--> statement-breakpoint +CREATE INDEX "bots_active_idx" ON "bots" USING btree ("is_active");--> statement-breakpoint +CREATE INDEX "follows_follower_idx" ON "follows" USING btree ("follower_id");--> statement-breakpoint +CREATE INDEX "follows_following_idx" ON "follows" USING btree ("following_id");--> statement-breakpoint +CREATE INDEX "handle_registry_updated_idx" ON "handle_registry" USING btree ("updated_at");--> statement-breakpoint +CREATE INDEX "likes_user_post_idx" ON "likes" USING btree ("user_id","post_id");--> statement-breakpoint +CREATE INDEX "media_user_idx" ON "media" USING btree ("user_id");--> statement-breakpoint +CREATE INDEX "media_post_idx" ON "media" USING btree ("post_id");--> statement-breakpoint +CREATE INDEX "mutes_user_idx" ON "mutes" USING btree ("user_id");--> statement-breakpoint +CREATE INDEX "notifications_user_idx" ON "notifications" USING btree ("user_id");--> statement-breakpoint +CREATE INDEX "notifications_created_idx" ON "notifications" USING btree ("created_at");--> statement-breakpoint +CREATE INDEX "posts_user_id_idx" ON "posts" USING btree ("user_id");--> statement-breakpoint +CREATE INDEX "posts_created_at_idx" ON "posts" USING btree ("created_at");--> statement-breakpoint +CREATE INDEX "posts_reply_to_idx" ON "posts" USING btree ("reply_to_id");--> statement-breakpoint +CREATE INDEX "posts_removed_idx" ON "posts" USING btree ("is_removed");--> statement-breakpoint +CREATE INDEX "remote_followers_user_idx" ON "remote_followers" USING btree ("user_id");--> statement-breakpoint +CREATE INDEX "remote_followers_actor_idx" ON "remote_followers" USING btree ("actor_url");--> statement-breakpoint +CREATE INDEX "remote_follows_follower_idx" ON "remote_follows" USING btree ("follower_id");--> statement-breakpoint +CREATE INDEX "remote_follows_target_idx" ON "remote_follows" USING btree ("target_handle");--> statement-breakpoint +CREATE INDEX "remote_posts_author_idx" ON "remote_posts" USING btree ("author_handle");--> statement-breakpoint +CREATE INDEX "remote_posts_published_idx" ON "remote_posts" USING btree ("published_at");--> statement-breakpoint +CREATE INDEX "remote_posts_ap_id_idx" ON "remote_posts" USING btree ("ap_id");--> statement-breakpoint +CREATE INDEX "reports_status_idx" ON "reports" USING btree ("status");--> statement-breakpoint +CREATE INDEX "reports_target_idx" ON "reports" USING btree ("target_type","target_id");--> statement-breakpoint +CREATE INDEX "reports_reporter_idx" ON "reports" USING btree ("reporter_id");--> statement-breakpoint +CREATE INDEX "sessions_token_idx" ON "sessions" USING btree ("token");--> statement-breakpoint +CREATE INDEX "sessions_user_idx" ON "sessions" USING btree ("user_id");--> statement-breakpoint +CREATE INDEX "users_handle_idx" ON "users" USING btree ("handle");--> statement-breakpoint +CREATE INDEX "users_did_idx" ON "users" USING btree ("did");--> statement-breakpoint +CREATE INDEX "users_suspended_idx" ON "users" USING btree ("is_suspended");--> statement-breakpoint +CREATE INDEX "users_silenced_idx" ON "users" USING btree ("is_silenced"); \ No newline at end of file diff --git a/drizzle/0001_remove_fetch_interval.sql b/drizzle/0001_remove_fetch_interval.sql new file mode 100644 index 0000000..47ea9ca --- /dev/null +++ b/drizzle/0001_remove_fetch_interval.sql @@ -0,0 +1,2 @@ +-- Remove fetch_interval_minutes column from bot_content_sources +ALTER TABLE "bot_content_sources" DROP COLUMN IF EXISTS "fetch_interval_minutes"; diff --git a/drizzle/0002_add_bot_id_to_posts.sql b/drizzle/0002_add_bot_id_to_posts.sql new file mode 100644 index 0000000..39a3c53 --- /dev/null +++ b/drizzle/0002_add_bot_id_to_posts.sql @@ -0,0 +1,5 @@ +-- Add bot_id column to posts table +ALTER TABLE "posts" ADD COLUMN "bot_id" uuid REFERENCES "bots"("id") ON DELETE SET NULL; + +-- Create index for bot_id +CREATE INDEX IF NOT EXISTS "posts_bot_id_idx" ON "posts" ("bot_id"); diff --git a/drizzle/0003_fix_bot_content_items_fk.sql b/drizzle/0003_fix_bot_content_items_fk.sql new file mode 100644 index 0000000..662a314 --- /dev/null +++ b/drizzle/0003_fix_bot_content_items_fk.sql @@ -0,0 +1,4 @@ +-- Fix bot_content_items post_id foreign key to allow cascade on delete +ALTER TABLE "bot_content_items" DROP CONSTRAINT IF EXISTS "bot_content_items_post_id_posts_id_fk"; +ALTER TABLE "bot_content_items" ADD CONSTRAINT "bot_content_items_post_id_posts_id_fk" + FOREIGN KEY ("post_id") REFERENCES "posts"("id") ON DELETE SET NULL; diff --git a/drizzle/0004_add_source_config.sql b/drizzle/0004_add_source_config.sql new file mode 100644 index 0000000..6f60b7a --- /dev/null +++ b/drizzle/0004_add_source_config.sql @@ -0,0 +1,2 @@ +-- Add source_config column for Brave News and News API query builder configurations +ALTER TABLE "bot_content_sources" ADD COLUMN "source_config" text; diff --git a/drizzle/0005_bots_as_users.sql b/drizzle/0005_bots_as_users.sql new file mode 100644 index 0000000..6c99ba0 --- /dev/null +++ b/drizzle/0005_bots_as_users.sql @@ -0,0 +1,34 @@ +-- Bots as First-Class Users Migration +-- This migration transforms bots to have their own user accounts + +-- Add bot-related fields to users table +ALTER TABLE "users" ADD COLUMN "is_bot" boolean DEFAULT false NOT NULL; +ALTER TABLE "users" ADD COLUMN "bot_owner_id" uuid REFERENCES "users"("id") ON DELETE CASCADE; + +-- Create indexes for bot fields +CREATE INDEX IF NOT EXISTS "users_is_bot_idx" ON "users" ("is_bot"); +CREATE INDEX IF NOT EXISTS "users_bot_owner_idx" ON "users" ("bot_owner_id"); + +-- Add owner_id to bots table (will be populated during migration) +ALTER TABLE "bots" ADD COLUMN "owner_id" uuid REFERENCES "users"("id") ON DELETE CASCADE; + +-- Copy existing userId to ownerId (existing bots were owned by the user) +UPDATE "bots" SET "owner_id" = "user_id"; + +-- Make owner_id NOT NULL after populating +ALTER TABLE "bots" ALTER COLUMN "owner_id" SET NOT NULL; + +-- Create index for owner_id +CREATE INDEX IF NOT EXISTS "bots_owner_id_idx" ON "bots" ("owner_id"); + +-- Remove columns that are now on the user account +-- Note: handle, bio, avatarUrl, publicKey, privateKeyEncrypted move to users table +-- We'll keep them for now and handle migration in application code +-- ALTER TABLE "bots" DROP COLUMN "handle"; +-- ALTER TABLE "bots" DROP COLUMN "bio"; +-- ALTER TABLE "bots" DROP COLUMN "avatar_url"; +-- ALTER TABLE "bots" DROP COLUMN "public_key"; +-- ALTER TABLE "bots" DROP COLUMN "private_key_encrypted"; + +-- Drop the handle index since handle is now on users +DROP INDEX IF EXISTS "bots_handle_idx"; diff --git a/drizzle/meta/0000_snapshot.json b/drizzle/meta/0000_snapshot.json new file mode 100644 index 0000000..8e51ad3 --- /dev/null +++ b/drizzle/meta/0000_snapshot.json @@ -0,0 +1,2897 @@ +{ + "id": "7025aca1-5d31-4612-98a9-9a5db99565f6", + "prevId": "00000000-0000-0000-0000-000000000000", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.blocks": { + "name": "blocks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "blocked_user_id": { + "name": "blocked_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "blocks_user_idx": { + "name": "blocks_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "blocks_user_id_users_id_fk": { + "name": "blocks_user_id_users_id_fk", + "tableFrom": "blocks", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "blocks_blocked_user_id_users_id_fk": { + "name": "blocks_blocked_user_id_users_id_fk", + "tableFrom": "blocks", + "tableTo": "users", + "columnsFrom": [ + "blocked_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.bot_activity_logs": { + "name": "bot_activity_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "bot_id": { + "name": "bot_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "details": { + "name": "details", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "success": { + "name": "success", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "bot_activity_logs_bot_idx": { + "name": "bot_activity_logs_bot_idx", + "columns": [ + { + "expression": "bot_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "bot_activity_logs_action_idx": { + "name": "bot_activity_logs_action_idx", + "columns": [ + { + "expression": "action", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "bot_activity_logs_created_idx": { + "name": "bot_activity_logs_created_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "bot_activity_logs_bot_id_bots_id_fk": { + "name": "bot_activity_logs_bot_id_bots_id_fk", + "tableFrom": "bot_activity_logs", + "tableTo": "bots", + "columnsFrom": [ + "bot_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.bot_content_items": { + "name": "bot_content_items", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "source_id": { + "name": "source_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "published_at": { + "name": "published_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "fetched_at": { + "name": "fetched_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "is_processed": { + "name": "is_processed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "processed_at": { + "name": "processed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "post_id": { + "name": "post_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "interest_score": { + "name": "interest_score", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "interest_reason": { + "name": "interest_reason", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "bot_content_items_source_idx": { + "name": "bot_content_items_source_idx", + "columns": [ + { + "expression": "source_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "bot_content_items_processed_idx": { + "name": "bot_content_items_processed_idx", + "columns": [ + { + "expression": "is_processed", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "bot_content_items_external_idx": { + "name": "bot_content_items_external_idx", + "columns": [ + { + "expression": "external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "bot_content_items_source_id_bot_content_sources_id_fk": { + "name": "bot_content_items_source_id_bot_content_sources_id_fk", + "tableFrom": "bot_content_items", + "tableTo": "bot_content_sources", + "columnsFrom": [ + "source_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "bot_content_items_post_id_posts_id_fk": { + "name": "bot_content_items_post_id_posts_id_fk", + "tableFrom": "bot_content_items", + "tableTo": "posts", + "columnsFrom": [ + "post_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.bot_content_sources": { + "name": "bot_content_sources", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "bot_id": { + "name": "bot_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "subreddit": { + "name": "subreddit", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "api_key_encrypted": { + "name": "api_key_encrypted", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "fetch_interval_minutes": { + "name": "fetch_interval_minutes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 30 + }, + "keywords": { + "name": "keywords", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "last_fetch_at": { + "name": "last_fetch_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "consecutive_errors": { + "name": "consecutive_errors", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "bot_content_sources_bot_idx": { + "name": "bot_content_sources_bot_idx", + "columns": [ + { + "expression": "bot_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "bot_content_sources_type_idx": { + "name": "bot_content_sources_type_idx", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "bot_content_sources_bot_id_bots_id_fk": { + "name": "bot_content_sources_bot_id_bots_id_fk", + "tableFrom": "bot_content_sources", + "tableTo": "bots", + "columnsFrom": [ + "bot_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.bot_mentions": { + "name": "bot_mentions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "bot_id": { + "name": "bot_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "post_id": { + "name": "post_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "author_id": { + "name": "author_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_processed": { + "name": "is_processed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "processed_at": { + "name": "processed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "response_post_id": { + "name": "response_post_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "is_remote": { + "name": "is_remote", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "remote_actor_url": { + "name": "remote_actor_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "bot_mentions_bot_idx": { + "name": "bot_mentions_bot_idx", + "columns": [ + { + "expression": "bot_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "bot_mentions_processed_idx": { + "name": "bot_mentions_processed_idx", + "columns": [ + { + "expression": "is_processed", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "bot_mentions_created_idx": { + "name": "bot_mentions_created_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "bot_mentions_bot_id_bots_id_fk": { + "name": "bot_mentions_bot_id_bots_id_fk", + "tableFrom": "bot_mentions", + "tableTo": "bots", + "columnsFrom": [ + "bot_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "bot_mentions_post_id_posts_id_fk": { + "name": "bot_mentions_post_id_posts_id_fk", + "tableFrom": "bot_mentions", + "tableTo": "posts", + "columnsFrom": [ + "post_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "bot_mentions_author_id_users_id_fk": { + "name": "bot_mentions_author_id_users_id_fk", + "tableFrom": "bot_mentions", + "tableTo": "users", + "columnsFrom": [ + "author_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "bot_mentions_response_post_id_posts_id_fk": { + "name": "bot_mentions_response_post_id_posts_id_fk", + "tableFrom": "bot_mentions", + "tableTo": "posts", + "columnsFrom": [ + "response_post_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.bot_rate_limits": { + "name": "bot_rate_limits", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "bot_id": { + "name": "bot_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "window_start": { + "name": "window_start", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "window_type": { + "name": "window_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "post_count": { + "name": "post_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "reply_count": { + "name": "reply_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "bot_rate_limits_bot_window_idx": { + "name": "bot_rate_limits_bot_window_idx", + "columns": [ + { + "expression": "bot_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "window_start", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "bot_rate_limits_bot_id_bots_id_fk": { + "name": "bot_rate_limits_bot_id_bots_id_fk", + "tableFrom": "bot_rate_limits", + "tableTo": "bots", + "columnsFrom": [ + "bot_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.bots": { + "name": "bots", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "handle": { + "name": "handle", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "bio": { + "name": "bio", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "personality_config": { + "name": "personality_config", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "llm_provider": { + "name": "llm_provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "llm_model": { + "name": "llm_model", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "llm_api_key_encrypted": { + "name": "llm_api_key_encrypted", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "schedule_config": { + "name": "schedule_config", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "autonomous_mode": { + "name": "autonomous_mode", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "is_suspended": { + "name": "is_suspended", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "suspension_reason": { + "name": "suspension_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "suspended_at": { + "name": "suspended_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "private_key_encrypted": { + "name": "private_key_encrypted", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_post_at": { + "name": "last_post_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "bots_user_id_idx": { + "name": "bots_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "bots_handle_idx": { + "name": "bots_handle_idx", + "columns": [ + { + "expression": "handle", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "bots_active_idx": { + "name": "bots_active_idx", + "columns": [ + { + "expression": "is_active", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "bots_user_id_users_id_fk": { + "name": "bots_user_id_users_id_fk", + "tableFrom": "bots", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "bots_handle_unique": { + "name": "bots_handle_unique", + "nullsNotDistinct": false, + "columns": [ + "handle" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.follows": { + "name": "follows", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "follower_id": { + "name": "follower_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "following_id": { + "name": "following_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "ap_id": { + "name": "ap_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "pending": { + "name": "pending", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "follows_follower_idx": { + "name": "follows_follower_idx", + "columns": [ + { + "expression": "follower_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "follows_following_idx": { + "name": "follows_following_idx", + "columns": [ + { + "expression": "following_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "follows_follower_id_users_id_fk": { + "name": "follows_follower_id_users_id_fk", + "tableFrom": "follows", + "tableTo": "users", + "columnsFrom": [ + "follower_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "follows_following_id_users_id_fk": { + "name": "follows_following_id_users_id_fk", + "tableFrom": "follows", + "tableTo": "users", + "columnsFrom": [ + "following_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "follows_ap_id_unique": { + "name": "follows_ap_id_unique", + "nullsNotDistinct": false, + "columns": [ + "ap_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.handle_registry": { + "name": "handle_registry", + "schema": "", + "columns": { + "handle": { + "name": "handle", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "did": { + "name": "did", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "node_domain": { + "name": "node_domain", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "registered_at": { + "name": "registered_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "handle_registry_updated_idx": { + "name": "handle_registry_updated_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.likes": { + "name": "likes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "post_id": { + "name": "post_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "ap_id": { + "name": "ap_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "likes_user_post_idx": { + "name": "likes_user_post_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "post_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "likes_user_id_users_id_fk": { + "name": "likes_user_id_users_id_fk", + "tableFrom": "likes", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "likes_post_id_posts_id_fk": { + "name": "likes_post_id_posts_id_fk", + "tableFrom": "likes", + "tableTo": "posts", + "columnsFrom": [ + "post_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "likes_ap_id_unique": { + "name": "likes_ap_id_unique", + "nullsNotDistinct": false, + "columns": [ + "ap_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.media": { + "name": "media", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "post_id": { + "name": "post_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "alt_text": { + "name": "alt_text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "width": { + "name": "width", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "height": { + "name": "height", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "media_user_idx": { + "name": "media_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "media_post_idx": { + "name": "media_post_idx", + "columns": [ + { + "expression": "post_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "media_user_id_users_id_fk": { + "name": "media_user_id_users_id_fk", + "tableFrom": "media", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "media_post_id_posts_id_fk": { + "name": "media_post_id_posts_id_fk", + "tableFrom": "media", + "tableTo": "posts", + "columnsFrom": [ + "post_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mutes": { + "name": "mutes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "muted_user_id": { + "name": "muted_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mutes_user_idx": { + "name": "mutes_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mutes_user_id_users_id_fk": { + "name": "mutes_user_id_users_id_fk", + "tableFrom": "mutes", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mutes_muted_user_id_users_id_fk": { + "name": "mutes_muted_user_id_users_id_fk", + "tableFrom": "mutes", + "tableTo": "users", + "columnsFrom": [ + "muted_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.nodes": { + "name": "nodes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "domain": { + "name": "domain", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "long_description": { + "name": "long_description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "rules": { + "name": "rules", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "banner_url": { + "name": "banner_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "accent_color": { + "name": "accent_color", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'#FFFFFF'" + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "nodes_domain_unique": { + "name": "nodes_domain_unique", + "nullsNotDistinct": false, + "columns": [ + "domain" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.notifications": { + "name": "notifications", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "actor_id": { + "name": "actor_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "post_id": { + "name": "post_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "read_at": { + "name": "read_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "notifications_user_idx": { + "name": "notifications_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "notifications_created_idx": { + "name": "notifications_created_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "notifications_user_id_users_id_fk": { + "name": "notifications_user_id_users_id_fk", + "tableFrom": "notifications", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "notifications_actor_id_users_id_fk": { + "name": "notifications_actor_id_users_id_fk", + "tableFrom": "notifications", + "tableTo": "users", + "columnsFrom": [ + "actor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "notifications_post_id_posts_id_fk": { + "name": "notifications_post_id_posts_id_fk", + "tableFrom": "notifications", + "tableTo": "posts", + "columnsFrom": [ + "post_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.posts": { + "name": "posts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reply_to_id": { + "name": "reply_to_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "repost_of_id": { + "name": "repost_of_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "likes_count": { + "name": "likes_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "reposts_count": { + "name": "reposts_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "replies_count": { + "name": "replies_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "is_removed": { + "name": "is_removed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "removed_at": { + "name": "removed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "removed_by": { + "name": "removed_by", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "removed_reason": { + "name": "removed_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ap_id": { + "name": "ap_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ap_url": { + "name": "ap_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "link_preview_url": { + "name": "link_preview_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "link_preview_title": { + "name": "link_preview_title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "link_preview_description": { + "name": "link_preview_description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "link_preview_image": { + "name": "link_preview_image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "posts_user_id_idx": { + "name": "posts_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "posts_created_at_idx": { + "name": "posts_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "posts_reply_to_idx": { + "name": "posts_reply_to_idx", + "columns": [ + { + "expression": "reply_to_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "posts_removed_idx": { + "name": "posts_removed_idx", + "columns": [ + { + "expression": "is_removed", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "posts_user_id_users_id_fk": { + "name": "posts_user_id_users_id_fk", + "tableFrom": "posts", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "posts_removed_by_users_id_fk": { + "name": "posts_removed_by_users_id_fk", + "tableFrom": "posts", + "tableTo": "users", + "columnsFrom": [ + "removed_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "posts_ap_id_unique": { + "name": "posts_ap_id_unique", + "nullsNotDistinct": false, + "columns": [ + "ap_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.remote_followers": { + "name": "remote_followers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "actor_url": { + "name": "actor_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "inbox_url": { + "name": "inbox_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "shared_inbox_url": { + "name": "shared_inbox_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "handle": { + "name": "handle", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "activity_id": { + "name": "activity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "remote_followers_user_idx": { + "name": "remote_followers_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "remote_followers_actor_idx": { + "name": "remote_followers_actor_idx", + "columns": [ + { + "expression": "actor_url", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "remote_followers_user_id_users_id_fk": { + "name": "remote_followers_user_id_users_id_fk", + "tableFrom": "remote_followers", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "remote_followers_actor_url_unique": { + "name": "remote_followers_actor_url_unique", + "nullsNotDistinct": false, + "columns": [ + "actor_url" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.remote_follows": { + "name": "remote_follows", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "follower_id": { + "name": "follower_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "target_handle": { + "name": "target_handle", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_actor_url": { + "name": "target_actor_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "inbox_url": { + "name": "inbox_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "activity_id": { + "name": "activity_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "bio": { + "name": "bio", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "remote_follows_follower_idx": { + "name": "remote_follows_follower_idx", + "columns": [ + { + "expression": "follower_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "remote_follows_target_idx": { + "name": "remote_follows_target_idx", + "columns": [ + { + "expression": "target_handle", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "remote_follows_follower_id_users_id_fk": { + "name": "remote_follows_follower_id_users_id_fk", + "tableFrom": "remote_follows", + "tableTo": "users", + "columnsFrom": [ + "follower_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.remote_posts": { + "name": "remote_posts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "ap_id": { + "name": "ap_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "author_handle": { + "name": "author_handle", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "author_actor_url": { + "name": "author_actor_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "author_display_name": { + "name": "author_display_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "author_avatar_url": { + "name": "author_avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "published_at": { + "name": "published_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "link_preview_url": { + "name": "link_preview_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "link_preview_title": { + "name": "link_preview_title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "link_preview_description": { + "name": "link_preview_description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "link_preview_image": { + "name": "link_preview_image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "media_json": { + "name": "media_json", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "fetched_at": { + "name": "fetched_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "remote_posts_author_idx": { + "name": "remote_posts_author_idx", + "columns": [ + { + "expression": "author_handle", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "remote_posts_published_idx": { + "name": "remote_posts_published_idx", + "columns": [ + { + "expression": "published_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "remote_posts_ap_id_idx": { + "name": "remote_posts_ap_id_idx", + "columns": [ + { + "expression": "ap_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "remote_posts_ap_id_unique": { + "name": "remote_posts_ap_id_unique", + "nullsNotDistinct": false, + "columns": [ + "ap_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.reports": { + "name": "reports", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "reporter_id": { + "name": "reporter_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "target_type": { + "name": "target_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_id": { + "name": "target_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'open'" + }, + "resolved_at": { + "name": "resolved_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "resolved_by": { + "name": "resolved_by", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "resolution_note": { + "name": "resolution_note", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "reports_status_idx": { + "name": "reports_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "reports_target_idx": { + "name": "reports_target_idx", + "columns": [ + { + "expression": "target_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "reports_reporter_idx": { + "name": "reports_reporter_idx", + "columns": [ + { + "expression": "reporter_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "reports_reporter_id_users_id_fk": { + "name": "reports_reporter_id_users_id_fk", + "tableFrom": "reports", + "tableTo": "users", + "columnsFrom": [ + "reporter_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "reports_resolved_by_users_id_fk": { + "name": "reports_resolved_by_users_id_fk", + "tableFrom": "reports", + "tableTo": "users", + "columnsFrom": [ + "resolved_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sessions": { + "name": "sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "sessions_token_idx": { + "name": "sessions_token_idx", + "columns": [ + { + "expression": "token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_user_idx": { + "name": "sessions_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "sessions_token_unique": { + "name": "sessions_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "did": { + "name": "did", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "handle": { + "name": "handle", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "bio": { + "name": "bio", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "header_url": { + "name": "header_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "private_key_encrypted": { + "name": "private_key_encrypted", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "node_id": { + "name": "node_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "is_suspended": { + "name": "is_suspended", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "suspension_reason": { + "name": "suspension_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "suspended_at": { + "name": "suspended_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "is_silenced": { + "name": "is_silenced", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "silence_reason": { + "name": "silence_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "silenced_at": { + "name": "silenced_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "moved_to": { + "name": "moved_to", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "moved_from": { + "name": "moved_from", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "migrated_at": { + "name": "migrated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "followers_count": { + "name": "followers_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "following_count": { + "name": "following_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "posts_count": { + "name": "posts_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "website": { + "name": "website", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "users_handle_idx": { + "name": "users_handle_idx", + "columns": [ + { + "expression": "handle", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "users_did_idx": { + "name": "users_did_idx", + "columns": [ + { + "expression": "did", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "users_suspended_idx": { + "name": "users_suspended_idx", + "columns": [ + { + "expression": "is_suspended", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "users_silenced_idx": { + "name": "users_silenced_idx", + "columns": [ + { + "expression": "is_silenced", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "users_node_id_nodes_id_fk": { + "name": "users_node_id_nodes_id_fk", + "tableFrom": "users", + "tableTo": "nodes", + "columnsFrom": [ + "node_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_did_unique": { + "name": "users_did_unique", + "nullsNotDistinct": false, + "columns": [ + "did" + ] + }, + "users_handle_unique": { + "name": "users_handle_unique", + "nullsNotDistinct": false, + "columns": [ + "handle" + ] + }, + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json new file mode 100644 index 0000000..2e658cb --- /dev/null +++ b/drizzle/meta/_journal.json @@ -0,0 +1,20 @@ +{ + "version": "7", + "dialect": "postgresql", + "entries": [ + { + "idx": 0, + "version": "7", + "when": 1769153858323, + "tag": "0000_clumsy_donald_blake", + "breakpoints": true + }, + { + "idx": 1, + "version": "7", + "when": 1769153858324, + "tag": "0004_add_source_config", + "breakpoints": true + } + ] +} \ No newline at end of file diff --git a/inspect_bots.ts b/inspect_bots.ts new file mode 100644 index 0000000..af8f94c --- /dev/null +++ b/inspect_bots.ts @@ -0,0 +1,13 @@ + +import { db } from './src/db'; +import { bots } from './src/db/schema'; + +async function main() { + const allBots = await db.select().from(bots); + console.log('Bot Count:', allBots.length); + if (allBots.length > 0) { + console.log('Bot Data:', JSON.stringify(allBots, null, 2)); + } +} + +main().then(() => process.exit(0)).catch(console.error); diff --git a/package-lock.json b/package-lock.json index c0f0b0e..00bec25 100644 --- a/package-lock.json +++ b/package-lock.json @@ -27,7 +27,7 @@ "devDependencies": { "@tailwindcss/postcss": "^4", "@types/bcryptjs": "^2.4.6", - "@types/node": "^20", + "@types/node": "^20.19.30", "@types/pg": "^8.16.0", "@types/react": "^19", "@types/react-dom": "^19", @@ -36,8 +36,11 @@ "drizzle-kit": "^0.31.8", "eslint": "^9", "eslint-config-next": "16.1.4", + "fast-check": "^4.5.3", "tailwindcss": "^4", - "typescript": "^5" + "tsx": "^4.21.0", + "typescript": "^5", + "vitest": "^4.0.18" } }, "node_modules/@alloc/quick-lru": { @@ -1011,7 +1014,6 @@ "integrity": "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/generator": "^7.28.6", @@ -3065,6 +3067,356 @@ "url": "https://github.com/sponsors/panva" } }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.56.0.tgz", + "integrity": "sha512-LNKIPA5k8PF1+jAFomGe3qN3bbIgJe/IlpDBwuVjrDKrJhVWywgnJvflMt/zkbVNLFtF1+94SljYQS6e99klnw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.56.0.tgz", + "integrity": "sha512-lfbVUbelYqXlYiU/HApNMJzT1E87UPGvzveGg2h0ktUNlOCxKlWuJ9jtfvs1sKHdwU4fzY7Pl8sAl49/XaEk6Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.56.0.tgz", + "integrity": "sha512-EgxD1ocWfhoD6xSOeEEwyE7tDvwTgZc8Bss7wCWe+uc7wO8G34HHCUH+Q6cHqJubxIAnQzAsyUsClt0yFLu06w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.56.0.tgz", + "integrity": "sha512-1vXe1vcMOssb/hOF8iv52A7feWW2xnu+c8BV4t1F//m9QVLTfNVpEdja5ia762j/UEJe2Z1jAmEqZAK42tVW3g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.56.0.tgz", + "integrity": "sha512-bof7fbIlvqsyv/DtaXSck4VYQ9lPtoWNFCB/JY4snlFuJREXfZnm+Ej6yaCHfQvofJDXLDMTVxWscVSuQvVWUQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.56.0.tgz", + "integrity": "sha512-KNa6lYHloW+7lTEkYGa37fpvPq+NKG/EHKM8+G/g9WDU7ls4sMqbVRV78J6LdNuVaeeK5WB9/9VAFbKxcbXKYg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.56.0.tgz", + "integrity": "sha512-E8jKK87uOvLrrLN28jnAAAChNq5LeCd2mGgZF+fGF5D507WlG/Noct3lP/QzQ6MrqJ5BCKNwI9ipADB6jyiq2A==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.56.0.tgz", + "integrity": "sha512-jQosa5FMYF5Z6prEpTCCmzCXz6eKr/tCBssSmQGEeozA9tkRUty/5Vx06ibaOP9RCrW1Pvb8yp3gvZhHwTDsJw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.56.0.tgz", + "integrity": "sha512-uQVoKkrC1KGEV6udrdVahASIsaF8h7iLG0U0W+Xn14ucFwi6uS539PsAr24IEF9/FoDtzMeeJXJIBo5RkbNWvQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.56.0.tgz", + "integrity": "sha512-vLZ1yJKLxhQLFKTs42RwTwa6zkGln+bnXc8ueFGMYmBTLfNu58sl5/eXyxRa2RarTkJbXl8TKPgfS6V5ijNqEA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.56.0.tgz", + "integrity": "sha512-FWfHOCub564kSE3xJQLLIC/hbKqHSVxy8vY75/YHHzWvbJL7aYJkdgwD/xGfUlL5UV2SB7otapLrcCj2xnF1dg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.56.0.tgz", + "integrity": "sha512-z1EkujxIh7nbrKL1lmIpqFTc/sr0u8Uk0zK/qIEFldbt6EDKWFk/pxFq3gYj4Bjn3aa9eEhYRlL3H8ZbPT1xvA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.56.0.tgz", + "integrity": "sha512-iNFTluqgdoQC7AIE8Q34R3AuPrJGJirj5wMUErxj22deOcY7XwZRaqYmB6ZKFHoVGqRcRd0mqO+845jAibKCkw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.56.0.tgz", + "integrity": "sha512-MtMeFVlD2LIKjp2sE2xM2slq3Zxf9zwVuw0jemsxvh1QOpHSsSzfNOTH9uYW9i1MXFxUSMmLpeVeUzoNOKBaWg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.56.0.tgz", + "integrity": "sha512-in+v6wiHdzzVhYKXIk5U74dEZHdKN9KH0Q4ANHOTvyXPG41bajYRsy7a8TPKbYPl34hU7PP7hMVHRvv/5aCSew==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.56.0.tgz", + "integrity": "sha512-yni2raKHB8m9NQpI9fPVwN754mn6dHQSbDTwxdr9SE0ks38DTjLMMBjrwvB5+mXrX+C0npX0CVeCUcvvvD8CNQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.56.0.tgz", + "integrity": "sha512-zhLLJx9nQPu7wezbxt2ut+CI4YlXi68ndEve16tPc/iwoylWS9B3FxpLS2PkmfYgDQtosah07Mj9E0khc3Y+vQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.56.0.tgz", + "integrity": "sha512-MVC6UDp16ZSH7x4rtuJPAEoE1RwS8N4oK9DLHy3FTEdFoUTCFVzMfJl/BVJ330C+hx8FfprA5Wqx4FhZXkj2Kw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.56.0.tgz", + "integrity": "sha512-ZhGH1eA4Qv0lxaV00azCIS1ChedK0V32952Md3FtnxSqZTBTd6tgil4nZT5cU8B+SIw3PFYkvyR4FKo2oyZIHA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.56.0.tgz", + "integrity": "sha512-O16XcmyDeFI9879pEcmtWvD/2nyxR9mF7Gs44lf1vGGx8Vg2DRNx11aVXBEqOQhWb92WN4z7fW/q4+2NYzCbBA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.56.0.tgz", + "integrity": "sha512-LhN/Reh+7F3RCgQIRbgw8ZMwUwyqJM+8pXNT6IIJAqm2IdKkzpCh/V9EdgOMBKuebIrzswqy4ATlrDgiOwbRcQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.56.0.tgz", + "integrity": "sha512-kbFsOObXp3LBULg1d3JIUQMa9Kv4UitDmpS+k0tinPBz3watcUiV2/LUDMMucA6pZO3WGE27P7DsfaN54l9ing==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.56.0.tgz", + "integrity": "sha512-vSSgny54D6P4vf2izbtFm/TcWYedw7f8eBrOiGGecyHyQB9q4Kqentjaj8hToe+995nob/Wv48pDqL5a62EWtg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.56.0.tgz", + "integrity": "sha512-FeCnkPCTHQJFbiGG49KjV5YGW/8b9rrXAM2Mz2kiIoktq2qsJxRD5giEMEOD2lPdgs72upzefaUvS+nc8E3UzQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.56.0.tgz", + "integrity": "sha512-H8AE9Ur/t0+1VXujj90w0HrSOuv0Nq9r1vSZF2t5km20NTfosQsGGUXDaKdQZzwuLts7IyL1fYT4hM95TI9c4g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, "node_modules/@rtsao/scc": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", @@ -3804,6 +4156,13 @@ "node": ">=18.0.0" } }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, "node_modules/@swc/helpers": { "version": "0.5.15", "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", @@ -4102,6 +4461,24 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -4139,7 +4516,6 @@ "integrity": "sha512-RmhMd/wD+CF8Dfo+cVIy3RR5cl8CyfXQ0tGgW6XBL8L4LM/UTEbNXYRbLwU6w+CgrKBNbrQWt4FUtTfaU5jSYQ==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "@types/node": "*", "pg-protocol": "*", @@ -4212,7 +4588,6 @@ "integrity": "sha512-Lpo8kgb/igvMIPeNV2rsYKTgaORYdO1XGVZ4Qz3akwOj0ySGYMPlQWa8BaLn0G63D1aSaAQ5ldR06wCpChQCjA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -4279,7 +4654,6 @@ "integrity": "sha512-nm3cvFN9SqZGXjmw5bZ6cGmvJSyJPn0wU9gHAZZHDnZl2wF9PhHv78Xf06E0MaNk4zLVHL8hb2/c32XvyJOLQg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.53.1", "@typescript-eslint/types": "8.53.1", @@ -4778,18 +5152,127 @@ "resolved": "https://registry.npmjs.org/@upstash/redis/-/redis-1.36.1.tgz", "integrity": "sha512-N6SjDcgXdOcTAF+7uNoY69o7hCspe9BcA7YjQdxVu5d25avljTwyLaHBW3krWjrP0FfocgMk94qyVtQbeDp39A==", "license": "MIT", - "peer": true, "dependencies": { "uncrypto": "^0.1.3" } }, + "node_modules/@vitest/expect": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.18.tgz", + "integrity": "sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.0.18", + "@vitest/utils": "4.0.18", + "chai": "^6.2.1", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.0.18.tgz", + "integrity": "sha512-HhVd0MDnzzsgevnOWCBj5Otnzobjy5wLBe4EdeeFGv8luMsGcYqDuFRMcttKWZA5vVO8RFjexVovXvAM4JoJDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.0.18", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.0.18.tgz", + "integrity": "sha512-P24GK3GulZWC5tz87ux0m8OADrQIUVDPIjjj65vBXYG17ZeU3qD7r+MNZ1RNv4l8CGU2vtTRqixrOi9fYk/yKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.0.18.tgz", + "integrity": "sha512-rpk9y12PGa22Jg6g5M3UVVnTS7+zycIGk9ZNGN+m6tZHKQb7jrP7/77WfZy13Y/EUDd52NDsLRQhYKtv7XfPQw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.0.18", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.0.18.tgz", + "integrity": "sha512-PCiV0rcl7jKQjbgYqjtakly6T1uwv/5BQ9SwBLekVg/EaYeQFPiXcgrC2Y7vDMA8dM1SUEAEV82kgSQIlXNMvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.0.18", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.0.18.tgz", + "integrity": "sha512-cbQt3PTSD7P2OARdVW3qWER5EGq7PHlvE+QfzSC0lbwO+xnt7+XH06ZzFjFRgzUX//JmpxrCu92VdwvEPlWSNw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.0.18.tgz", + "integrity": "sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.0.18", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/acorn": { "version": "8.15.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -5017,6 +5500,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/ast-types-flow": { "version": "0.0.8", "resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.8.tgz", @@ -5142,7 +5635,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -5244,6 +5736,16 @@ ], "license": "CC-BY-4.0" }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -5788,6 +6290,13 @@ "node": ">= 0.4" } }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, "node_modules/es-object-atoms": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", @@ -5855,7 +6364,6 @@ "dev": true, "hasInstallScript": true, "license": "MIT", - "peer": true, "bin": { "esbuild": "bin/esbuild" }, @@ -5933,7 +6441,6 @@ "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -6119,7 +6626,6 @@ "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -6343,6 +6849,16 @@ "node": ">=4.0" } }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, "node_modules/esutils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", @@ -6353,6 +6869,39 @@ "node": ">=0.10.0" } }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fast-check": { + "version": "4.5.3", + "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-4.5.3.tgz", + "integrity": "sha512-IE9csY7lnhxBnA8g/WI5eg/hygA6MGWJMSNfFRrBlXUciADEhS1EDB0SIsMSvzubzIlOBbVITSsypCsW717poA==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT", + "dependencies": { + "pure-rand": "^7.0.0" + }, + "engines": { + "node": ">=12.17.0" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -6512,6 +7061,21 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/function-bind": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", @@ -8140,6 +8704,17 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/obug": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT" + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -8248,12 +8823,18 @@ "dev": true, "license": "MIT" }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, "node_modules/pg": { "version": "8.17.2", "resolved": "https://registry.npmjs.org/pg/-/pg-8.17.2.tgz", "integrity": "sha512-vjbKdiBJRqzcYw1fNU5KuHyYvdJ1qpcQg1CeBrHFqV1pWgHeVR6j/+kX0E1AAXfyuLUGY1ICrN2ELKA/z2HWzw==", "license": "MIT", - "peer": true, "dependencies": { "pg-connection-string": "^2.10.1", "pg-pool": "^3.11.0", @@ -8440,7 +9021,6 @@ "resolved": "https://registry.npmjs.org/preact/-/preact-10.24.3.tgz", "integrity": "sha512-Z2dPnBnMUfyQfSQ+GBdsGa16hz35YmLmtTLhM169uW944hYL6xzTYkJjC07j+Wosz733pMWx0fgON3JNw1jJQA==", "license": "MIT", - "peer": true, "funding": { "type": "opencollective", "url": "https://opencollective.com/preact" @@ -8487,6 +9067,23 @@ "node": ">=6" } }, + "node_modules/pure-rand": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-7.0.1.tgz", + "integrity": "sha512-oTUZM/NAZS8p7ANR3SHh30kXB+zK2r2BPcEn/awJIbOvq82WoMN4p62AWWp3Hhw50G0xMsw1mhIBLqHw64EcNQ==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT" + }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -8513,7 +9110,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz", "integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -8523,7 +9119,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz", "integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -8634,6 +9229,51 @@ "node": ">=0.10.0" } }, + "node_modules/rollup": { + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.56.0.tgz", + "integrity": "sha512-9FwVqlgUHzbXtDg9RCMgodF3Ua4Na6Gau+Sdt9vyCN4RhHfVKX2DCHy3BjMLTDd47ITDhYAnTwGulWTblJSDLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.56.0", + "@rollup/rollup-android-arm64": "4.56.0", + "@rollup/rollup-darwin-arm64": "4.56.0", + "@rollup/rollup-darwin-x64": "4.56.0", + "@rollup/rollup-freebsd-arm64": "4.56.0", + "@rollup/rollup-freebsd-x64": "4.56.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.56.0", + "@rollup/rollup-linux-arm-musleabihf": "4.56.0", + "@rollup/rollup-linux-arm64-gnu": "4.56.0", + "@rollup/rollup-linux-arm64-musl": "4.56.0", + "@rollup/rollup-linux-loong64-gnu": "4.56.0", + "@rollup/rollup-linux-loong64-musl": "4.56.0", + "@rollup/rollup-linux-ppc64-gnu": "4.56.0", + "@rollup/rollup-linux-ppc64-musl": "4.56.0", + "@rollup/rollup-linux-riscv64-gnu": "4.56.0", + "@rollup/rollup-linux-riscv64-musl": "4.56.0", + "@rollup/rollup-linux-s390x-gnu": "4.56.0", + "@rollup/rollup-linux-x64-gnu": "4.56.0", + "@rollup/rollup-linux-x64-musl": "4.56.0", + "@rollup/rollup-openbsd-x64": "4.56.0", + "@rollup/rollup-openharmony-arm64": "4.56.0", + "@rollup/rollup-win32-arm64-msvc": "4.56.0", + "@rollup/rollup-win32-ia32-msvc": "4.56.0", + "@rollup/rollup-win32-x64-gnu": "4.56.0", + "@rollup/rollup-win32-x64-msvc": "4.56.0", + "fsevents": "~2.3.2" + } + }, "node_modules/run-parallel": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", @@ -8935,6 +9575,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/source-map": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", @@ -8981,6 +9628,20 @@ "dev": true, "license": "MIT" }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, "node_modules/stop-iteration-iterator": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", @@ -9213,6 +9874,23 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz", + "integrity": "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/tinyglobby": { "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", @@ -9254,7 +9932,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -9262,6 +9939,16 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/tinyrainbow": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.0.3.tgz", + "integrity": "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -9320,6 +10007,510 @@ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "license": "0BSD" }, + "node_modules/tsx": { + "version": "4.21.0", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", + "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.27.0", + "get-tsconfig": "^4.7.5" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/tsx/node_modules/@esbuild/aix-ppc64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz", + "integrity": "sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/android-arm": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.2.tgz", + "integrity": "sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/android-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.2.tgz", + "integrity": "sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/android-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.2.tgz", + "integrity": "sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/darwin-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.2.tgz", + "integrity": "sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/darwin-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.2.tgz", + "integrity": "sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.2.tgz", + "integrity": "sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/freebsd-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.2.tgz", + "integrity": "sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-arm": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.2.tgz", + "integrity": "sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.2.tgz", + "integrity": "sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-ia32": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.2.tgz", + "integrity": "sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-loong64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.2.tgz", + "integrity": "sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-mips64el": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.2.tgz", + "integrity": "sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-ppc64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.2.tgz", + "integrity": "sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-riscv64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.2.tgz", + "integrity": "sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-s390x": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.2.tgz", + "integrity": "sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.2.tgz", + "integrity": "sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.2.tgz", + "integrity": "sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/netbsd-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.2.tgz", + "integrity": "sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.2.tgz", + "integrity": "sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/openbsd-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.2.tgz", + "integrity": "sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.2.tgz", + "integrity": "sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/sunos-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.2.tgz", + "integrity": "sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/win32-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.2.tgz", + "integrity": "sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/win32-ia32": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.2.tgz", + "integrity": "sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/win32-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.2.tgz", + "integrity": "sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/esbuild": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz", + "integrity": "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.2", + "@esbuild/android-arm": "0.27.2", + "@esbuild/android-arm64": "0.27.2", + "@esbuild/android-x64": "0.27.2", + "@esbuild/darwin-arm64": "0.27.2", + "@esbuild/darwin-x64": "0.27.2", + "@esbuild/freebsd-arm64": "0.27.2", + "@esbuild/freebsd-x64": "0.27.2", + "@esbuild/linux-arm": "0.27.2", + "@esbuild/linux-arm64": "0.27.2", + "@esbuild/linux-ia32": "0.27.2", + "@esbuild/linux-loong64": "0.27.2", + "@esbuild/linux-mips64el": "0.27.2", + "@esbuild/linux-ppc64": "0.27.2", + "@esbuild/linux-riscv64": "0.27.2", + "@esbuild/linux-s390x": "0.27.2", + "@esbuild/linux-x64": "0.27.2", + "@esbuild/netbsd-arm64": "0.27.2", + "@esbuild/netbsd-x64": "0.27.2", + "@esbuild/openbsd-arm64": "0.27.2", + "@esbuild/openbsd-x64": "0.27.2", + "@esbuild/openharmony-arm64": "0.27.2", + "@esbuild/sunos-x64": "0.27.2", + "@esbuild/win32-arm64": "0.27.2", + "@esbuild/win32-ia32": "0.27.2", + "@esbuild/win32-x64": "0.27.2" + } + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -9417,7 +10608,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -9571,6 +10761,687 @@ "uuid": "dist/esm/bin/uuid" } }, + "node_modules/vite": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", + "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/@esbuild/aix-ppc64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz", + "integrity": "sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/android-arm": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.2.tgz", + "integrity": "sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/android-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.2.tgz", + "integrity": "sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/android-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.2.tgz", + "integrity": "sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/darwin-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.2.tgz", + "integrity": "sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/darwin-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.2.tgz", + "integrity": "sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.2.tgz", + "integrity": "sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/freebsd-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.2.tgz", + "integrity": "sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-arm": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.2.tgz", + "integrity": "sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.2.tgz", + "integrity": "sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-ia32": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.2.tgz", + "integrity": "sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-loong64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.2.tgz", + "integrity": "sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-mips64el": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.2.tgz", + "integrity": "sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-ppc64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.2.tgz", + "integrity": "sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-riscv64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.2.tgz", + "integrity": "sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-s390x": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.2.tgz", + "integrity": "sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.2.tgz", + "integrity": "sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.2.tgz", + "integrity": "sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/netbsd-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.2.tgz", + "integrity": "sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.2.tgz", + "integrity": "sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/openbsd-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.2.tgz", + "integrity": "sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.2.tgz", + "integrity": "sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/sunos-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.2.tgz", + "integrity": "sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.2.tgz", + "integrity": "sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-ia32": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.2.tgz", + "integrity": "sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.2.tgz", + "integrity": "sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/esbuild": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz", + "integrity": "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.2", + "@esbuild/android-arm": "0.27.2", + "@esbuild/android-arm64": "0.27.2", + "@esbuild/android-x64": "0.27.2", + "@esbuild/darwin-arm64": "0.27.2", + "@esbuild/darwin-x64": "0.27.2", + "@esbuild/freebsd-arm64": "0.27.2", + "@esbuild/freebsd-x64": "0.27.2", + "@esbuild/linux-arm": "0.27.2", + "@esbuild/linux-arm64": "0.27.2", + "@esbuild/linux-ia32": "0.27.2", + "@esbuild/linux-loong64": "0.27.2", + "@esbuild/linux-mips64el": "0.27.2", + "@esbuild/linux-ppc64": "0.27.2", + "@esbuild/linux-riscv64": "0.27.2", + "@esbuild/linux-s390x": "0.27.2", + "@esbuild/linux-x64": "0.27.2", + "@esbuild/netbsd-arm64": "0.27.2", + "@esbuild/netbsd-x64": "0.27.2", + "@esbuild/openbsd-arm64": "0.27.2", + "@esbuild/openbsd-x64": "0.27.2", + "@esbuild/openharmony-arm64": "0.27.2", + "@esbuild/sunos-x64": "0.27.2", + "@esbuild/win32-arm64": "0.27.2", + "@esbuild/win32-ia32": "0.27.2", + "@esbuild/win32-x64": "0.27.2" + } + }, + "node_modules/vite/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/vitest": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.0.18.tgz", + "integrity": "sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.0.18", + "@vitest/mocker": "4.0.18", + "@vitest/pretty-format": "4.0.18", + "@vitest/runner": "4.0.18", + "@vitest/snapshot": "4.0.18", + "@vitest/spy": "4.0.18", + "@vitest/utils": "4.0.18", + "es-module-lexer": "^1.7.0", + "expect-type": "^1.2.2", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^3.10.0", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.0.3", + "vite": "^6.0.0 || ^7.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.0.18", + "@vitest/browser-preview": "4.0.18", + "@vitest/browser-webdriverio": "4.0.18", + "@vitest/ui": "4.0.18", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/vitest/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -9676,6 +11547,23 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", @@ -9720,7 +11608,6 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.5.tgz", "integrity": "sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g==", "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/package.json b/package.json index c2c2fdd..34d4074 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,9 @@ "db:generate": "drizzle-kit generate", "db:push": "drizzle-kit push", "db:studio": "drizzle-kit studio", - "type-check": "tsc --noEmit" + "type-check": "tsc --noEmit", + "test": "vitest run", + "test:watch": "vitest" }, "dependencies": { "@aws-sdk/client-s3": "^3.972.0", @@ -32,7 +34,7 @@ "devDependencies": { "@tailwindcss/postcss": "^4", "@types/bcryptjs": "^2.4.6", - "@types/node": "^20", + "@types/node": "^20.19.30", "@types/pg": "^8.16.0", "@types/react": "^19", "@types/react-dom": "^19", @@ -41,7 +43,10 @@ "drizzle-kit": "^0.31.8", "eslint": "^9", "eslint-config-next": "16.1.4", + "fast-check": "^4.5.3", "tailwindcss": "^4", - "typescript": "^5" + "tsx": "^4.21.0", + "typescript": "^5", + "vitest": "^4.0.18" } } diff --git a/src/app/[handle]/page.tsx b/src/app/[handle]/page.tsx index a773d91..845d2bc 100644 --- a/src/app/[handle]/page.tsx +++ b/src/app/[handle]/page.tsx @@ -9,6 +9,14 @@ import { User, Post } from '@/lib/types'; import AutoTextarea from '@/components/AutoTextarea'; import { Rocket } from 'lucide-react'; import { formatFullHandle } from '@/lib/utils/handle'; +import { Bot } from 'lucide-react'; + +interface BotOwner { + id: string; + handle: string; + displayName?: string | null; + avatarUrl?: string | null; +} interface UserSummary { id: string; @@ -16,6 +24,7 @@ interface UserSummary { displayName?: string | null; bio?: string | null; avatarUrl?: string | null; + isBot?: boolean; } // Strip HTML tags from a string @@ -35,7 +44,27 @@ function UserRow({ user }: { user: UserSummary }) { )}
-
{user.displayName || user.handle}
+
+ {user.displayName || user.handle} + {user.isBot && ( + + + AI Account + + )} +
{formatFullHandle(user.handle)}
{user.bio && stripHtml(user.bio) && (
{stripHtml(user.bio)}
@@ -395,6 +424,36 @@ export default function ProfilePage() {
)} + {/* Bot indicator and owner info */} + {user.isBot && ( +
+ + + Automated account + {(user as any).botOwner && ( + <> + {' · Managed by '} + + @{(user as any).botOwner.handle} + + + )} + +
+ )} +
-
+
{user.displayName || user.handle} + {user.isBot && ( + + + AI Account + + )}
{user.suspensionReason && (
Suspension: {user.suspensionReason}
diff --git a/src/app/api/account/export/route.ts b/src/app/api/account/export/route.ts index 7c3f0e4..c5c7b51 100644 --- a/src/app/api/account/export/route.ts +++ b/src/app/api/account/export/route.ts @@ -151,11 +151,14 @@ export async function POST(req: NextRequest) { const exportFollowing: ExportFollowing[] = [ // Local follows - ...userFollowing.map(f => ({ - actorUrl: `https://${nodeDomain}/users/${f.following.handle}`, - handle: f.following.handle, - isRemote: false, - })), + ...userFollowing.map(f => { + const followingUser = f.following as { handle: string }; + return { + actorUrl: `https://${nodeDomain}/users/${followingUser.handle}`, + handle: followingUser.handle, + isRemote: false, + }; + }), // Remote follows ...userRemoteFollowing.map(f => ({ actorUrl: f.targetActorUrl, diff --git a/src/app/api/admin/posts/route.ts b/src/app/api/admin/posts/route.ts index 67d767c..033e21d 100644 --- a/src/app/api/admin/posts/route.ts +++ b/src/app/api/admin/posts/route.ts @@ -31,18 +31,21 @@ export async function GET(request: Request) { limit, }); - const sanitized = results.map((post) => ({ - id: post.id, - content: post.content, - createdAt: post.createdAt, - isRemoved: post.isRemoved, - removedReason: post.removedReason, - author: { - id: post.author.id, - handle: post.author.handle, - displayName: post.author.displayName, - }, - })); + const sanitized = results.map((post) => { + const author = post.author as { id: string; handle: string; displayName: string | null }; + return { + id: post.id, + content: post.content, + createdAt: post.createdAt, + isRemoved: post.isRemoved, + removedReason: post.removedReason, + author: { + id: author.id, + handle: author.handle, + displayName: author.displayName, + }, + }; + }); return NextResponse.json({ posts: sanitized }); } catch (error) { diff --git a/src/app/api/admin/reports/route.ts b/src/app/api/admin/reports/route.ts index f5eb5e3..79daa4d 100644 --- a/src/app/api/admin/reports/route.ts +++ b/src/app/api/admin/reports/route.ts @@ -44,17 +44,20 @@ export async function GET(request: Request) { }) : []; - const postTargets = postTargetsRaw.map((post) => ({ - id: post.id, - content: post.content, - createdAt: post.createdAt, - isRemoved: post.isRemoved, - author: { - id: post.author.id, - handle: post.author.handle, - displayName: post.author.displayName, - }, - })); + const postTargets = postTargetsRaw.map((post) => { + const author = post.author as { id: string; handle: string; displayName: string | null }; + return { + id: post.id, + content: post.content, + createdAt: post.createdAt, + isRemoved: post.isRemoved, + author: { + id: author.id, + handle: author.handle, + displayName: author.displayName, + }, + }; + }); const userTargets = userTargetsRaw.map((user) => ({ id: user.id, @@ -67,24 +70,30 @@ export async function GET(request: Request) { const postMap = new Map(postTargets.map((post) => [post.id, post])); const userMap = new Map(userTargets.map((user) => [user.id, user])); - const reportsWithTargets = reportRows.map((report) => ({ - id: report.id, - targetType: report.targetType, - targetId: report.targetId, - reason: report.reason, - status: report.status, - createdAt: report.createdAt, - reporter: report.reporter - ? { id: report.reporter.id, handle: report.reporter.handle } - : null, - resolver: report.resolver - ? { id: report.resolver.id, handle: report.resolver.handle } - : null, - target: - report.targetType === 'post' - ? postMap.get(report.targetId) || null - : userMap.get(report.targetId) || null, - })); + type UserInfo = { id: string; handle: string }; + + const reportsWithTargets = reportRows.map((report) => { + const reporter = report.reporter as UserInfo | null; + const resolver = report.resolver as UserInfo | null; + return { + id: report.id, + targetType: report.targetType, + targetId: report.targetId, + reason: report.reason, + status: report.status, + createdAt: report.createdAt, + reporter: reporter + ? { id: reporter.id, handle: reporter.handle } + : null, + resolver: resolver + ? { id: resolver.id, handle: resolver.handle } + : null, + target: + report.targetType === 'post' + ? postMap.get(report.targetId) || null + : userMap.get(report.targetId) || null, + }; + }); return NextResponse.json({ reports: reportsWithTargets }); } catch (error) { diff --git a/src/app/api/admin/users/route.ts b/src/app/api/admin/users/route.ts index ea64f5b..61cc791 100644 --- a/src/app/api/admin/users/route.ts +++ b/src/app/api/admin/users/route.ts @@ -24,6 +24,7 @@ export async function GET(request: Request) { isSilenced: users.isSilenced, silenceReason: users.silenceReason, createdAt: users.createdAt, + isBot: users.isBot, }) .from(users) .orderBy(desc(users.createdAt)) diff --git a/src/app/api/bots/[id]/api-key/route.ts b/src/app/api/bots/[id]/api-key/route.ts new file mode 100644 index 0000000..5e7b375 --- /dev/null +++ b/src/app/api/bots/[id]/api-key/route.ts @@ -0,0 +1,152 @@ +/** + * Bot API Key Management Routes + * + * POST /api/bots/[id]/api-key - Set/update LLM API key + * DELETE /api/bots/[id]/api-key - Remove API key + * + * Requirements: 2.1, 2.2, 2.4 + */ + +import { NextResponse } from 'next/server'; +import { requireAuth } from '@/lib/auth'; +import { z } from 'zod'; +import { + getBotById, + userOwnsBot, + setApiKey, + removeApiKey, + BotNotFoundError, + BotValidationError, +} from '@/lib/bots/botManager'; + +type RouteContext = { params: Promise<{ id: string }> }; + +// Schema for setting API key +const setApiKeySchema = z.object({ + apiKey: z.string().min(1, 'API key is required'), + provider: z.enum(['openrouter', 'openai', 'anthropic']).optional(), +}); + +/** + * POST /api/bots/[id]/api-key - Set/update LLM API key + * + * Requires authentication. + * Sets or updates the API key for the bot's LLM provider. + * The API key is validated and encrypted before storage. + * + * Validates: Requirements 2.1, 2.2, 2.4 + */ +export async function POST(request: Request, context: RouteContext) { + try { + const user = await requireAuth(); + const { id } = await context.params; + const body = await request.json(); + const data = setApiKeySchema.parse(body); + + // Check if user owns the bot + const isOwner = await userOwnsBot(user.id, id); + if (!isOwner) { + // Check if bot exists at all + const bot = await getBotById(id); + if (!bot) { + return NextResponse.json({ error: 'Bot not found' }, { status: 404 }); + } + return NextResponse.json({ error: 'Not authorized' }, { status: 403 }); + } + + // Set the API key (validates format and encrypts) + await setApiKey(id, data.apiKey, data.provider); + + return NextResponse.json({ + success: true, + message: 'API key updated successfully', + }); + } catch (error) { + console.error('Set API key error:', error); + + if (error instanceof z.ZodError) { + return NextResponse.json( + { error: 'Invalid input', details: error.issues }, + { status: 400 } + ); + } + + if (error instanceof Error && error.message === 'Authentication required') { + return NextResponse.json({ error: 'Authentication required' }, { status: 401 }); + } + + if (error instanceof BotNotFoundError) { + return NextResponse.json( + { error: error.message, code: error.code }, + { status: 404 } + ); + } + + if (error instanceof BotValidationError) { + return NextResponse.json( + { error: error.message, code: error.code }, + { status: 400 } + ); + } + + return NextResponse.json( + { error: 'Failed to set API key' }, + { status: 500 } + ); + } +} + +/** + * DELETE /api/bots/[id]/api-key - Remove API key + * + * Requires authentication. + * Removes the API key from the bot, disabling LLM functionality. + * + * Note: Since the llmApiKeyEncrypted field is NOT NULL in the schema, + * this sets the key to an empty encrypted value, effectively disabling it. + * + * Validates: Requirements 2.4 + */ +export async function DELETE(_request: Request, context: RouteContext) { + try { + const user = await requireAuth(); + const { id } = await context.params; + + // Check if user owns the bot + const isOwner = await userOwnsBot(user.id, id); + if (!isOwner) { + // Check if bot exists at all + const bot = await getBotById(id); + if (!bot) { + return NextResponse.json({ error: 'Bot not found' }, { status: 404 }); + } + return NextResponse.json({ error: 'Not authorized' }, { status: 403 }); + } + + // Remove the API key + await removeApiKey(id); + + return NextResponse.json({ + success: true, + message: 'API key removed successfully', + }); + } catch (error) { + console.error('Remove API key error:', error); + + if (error instanceof Error && error.message === 'Authentication required') { + return NextResponse.json({ error: 'Authentication required' }, { status: 401 }); + } + + if (error instanceof BotNotFoundError) { + return NextResponse.json( + { error: error.message, code: error.code }, + { status: 404 } + ); + } + + return NextResponse.json( + { error: 'Failed to remove API key' }, + { status: 500 } + ); + } +} diff --git a/src/app/api/bots/[id]/api-key/status/route.ts b/src/app/api/bots/[id]/api-key/status/route.ts new file mode 100644 index 0000000..37fa26b --- /dev/null +++ b/src/app/api/bots/[id]/api-key/status/route.ts @@ -0,0 +1,70 @@ +/** + * Bot API Key Status Route + * + * GET /api/bots/[id]/api-key/status - Check if API key is configured + * + * Requirements: 2.1, 2.2 + */ + +import { NextResponse } from 'next/server'; +import { requireAuth } from '@/lib/auth'; +import { + getBotById, + userOwnsBot, + getApiKeyStatus, + BotNotFoundError, +} from '@/lib/bots/botManager'; + +type RouteContext = { params: Promise<{ id: string }> }; + +/** + * GET /api/bots/[id]/api-key/status - Check if API key is configured + * + * Requires authentication. + * Returns whether an API key is configured for the bot (not the key itself). + * + * Validates: Requirements 2.1, 2.2 + */ +export async function GET(request: Request, context: RouteContext) { + try { + const user = await requireAuth(); + const { id } = await context.params; + + // Check if user owns the bot + const isOwner = await userOwnsBot(user.id, id); + if (!isOwner) { + // Check if bot exists at all + const bot = await getBotById(id); + if (!bot) { + return NextResponse.json({ error: 'Bot not found' }, { status: 404 }); + } + return NextResponse.json({ error: 'Not authorized' }, { status: 403 }); + } + + // Get API key status + const status = await getApiKeyStatus(id); + + return NextResponse.json({ + success: true, + ...status, + }); + } catch (error) { + console.error('Get API key status error:', error); + + if (error instanceof Error && error.message === 'Authentication required') { + return NextResponse.json({ error: 'Authentication required' }, { status: 401 }); + } + + if (error instanceof BotNotFoundError) { + return NextResponse.json( + { error: error.message, code: error.code }, + { status: 404 } + ); + } + + return NextResponse.json( + { error: 'Failed to get API key status' }, + { status: 500 } + ); + } +} diff --git a/src/app/api/bots/[id]/logs/errors/route.ts b/src/app/api/bots/[id]/logs/errors/route.ts new file mode 100644 index 0000000..d33e4db --- /dev/null +++ b/src/app/api/bots/[id]/logs/errors/route.ts @@ -0,0 +1,65 @@ +/** + * Bot Error Logs API Route + * + * GET /api/bots/[id]/logs/errors - Get error logs only + * + * Requirements: 8.6 + */ + +import { NextRequest, NextResponse } from 'next/server'; +import { getSession } from '@/lib/auth'; +import { db, bots } from '@/db'; +import { eq } from 'drizzle-orm'; +import { getErrorLogs } from '@/lib/bots/activityLogger'; + +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + const session = await getSession(); + if (!session?.user?.id) { + return NextResponse.json( + { error: 'Authentication required' }, + { status: 401 } + ); + } + + const { id: botId } = await params; + + // Verify bot exists and user owns it + const bot = await db.query.bots.findFirst({ + where: eq(bots.id, botId), + columns: { id: true, userId: true }, + }); + + if (!bot) { + return NextResponse.json( + { error: 'Bot not found' }, + { status: 404 } + ); + } + + if (bot.userId !== session.user.id) { + return NextResponse.json( + { error: 'Not authorized' }, + { status: 403 } + ); + } + + // Parse limit + const { searchParams } = new URL(request.url); + const limit = searchParams.get('limit') ? parseInt(searchParams.get('limit')!) : 50; + + // Get error logs + const logs = await getErrorLogs(botId, limit); + + return NextResponse.json({ logs, count: logs.length }); + } catch (error) { + console.error('Error fetching error logs:', error); + return NextResponse.json( + { error: 'Failed to fetch error logs' }, + { status: 500 } + ); + } +} diff --git a/src/app/api/bots/[id]/logs/route.ts b/src/app/api/bots/[id]/logs/route.ts new file mode 100644 index 0000000..03ae609 --- /dev/null +++ b/src/app/api/bots/[id]/logs/route.ts @@ -0,0 +1,75 @@ +/** + * Bot Activity Logs API Route + * + * GET /api/bots/[id]/logs - Get activity logs with filters + * + * Requirements: 8.2, 8.6 + */ + +import { NextRequest, NextResponse } from 'next/server'; +import { getSession } from '@/lib/auth'; +import { db, bots } from '@/db'; +import { eq } from 'drizzle-orm'; +import { getLogsForBot, type ActionType } from '@/lib/bots/activityLogger'; + +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + const session = await getSession(); + if (!session?.user?.id) { + return NextResponse.json( + { error: 'Authentication required' }, + { status: 401 } + ); + } + + const { id: botId } = await params; + + // Verify bot exists and user owns it + const bot = await db.query.bots.findFirst({ + where: eq(bots.id, botId), + columns: { id: true, userId: true }, + }); + + if (!bot) { + return NextResponse.json( + { error: 'Bot not found' }, + { status: 404 } + ); + } + + if (bot.userId !== session.user.id) { + return NextResponse.json( + { error: 'Not authorized' }, + { status: 403 } + ); + } + + // Parse query parameters + const { searchParams } = new URL(request.url); + const actionTypes = searchParams.get('actionTypes')?.split(',') as ActionType[] | undefined; + const startDate = searchParams.get('startDate') ? new Date(searchParams.get('startDate')!) : undefined; + const endDate = searchParams.get('endDate') ? new Date(searchParams.get('endDate')!) : undefined; + const limit = searchParams.get('limit') ? parseInt(searchParams.get('limit')!) : undefined; + const offset = searchParams.get('offset') ? parseInt(searchParams.get('offset')!) : undefined; + + // Get logs + const logs = await getLogsForBot(botId, { + actionTypes, + startDate, + endDate, + limit, + offset, + }); + + return NextResponse.json({ logs, count: logs.length }); + } catch (error) { + console.error('Error fetching bot logs:', error); + return NextResponse.json( + { error: 'Failed to fetch logs' }, + { status: 500 } + ); + } +} diff --git a/src/app/api/bots/[id]/mentions/[mid]/respond/route.ts b/src/app/api/bots/[id]/mentions/[mid]/respond/route.ts new file mode 100644 index 0000000..794f2bc --- /dev/null +++ b/src/app/api/bots/[id]/mentions/[mid]/respond/route.ts @@ -0,0 +1,117 @@ +/** + * Bot Mention Response API Route + * + * POST /api/bots/[id]/mentions/[mid]/respond - Manually respond to a mention + * + * Requirements: 7.1 + */ + +import { NextRequest, NextResponse } from 'next/server'; +import { getSession } from '@/lib/auth'; +import { db, bots } from '@/db'; +import { eq } from 'drizzle-orm'; +import { processMention, getMentionById } from '@/lib/bots/mentionHandler'; + +/** + * POST /api/bots/[id]/mentions/[mid]/respond + * + * Manually trigger a response to a specific mention. + * Checks rate limits and generates a reply using the bot's LLM. + * + * Validates: Requirements 7.1 + */ +export async function POST( + request: NextRequest, + { params }: { params: Promise<{ id: string; mid: string }> } +) { + try { + // Check authentication + const session = await getSession(); + if (!session?.user?.id) { + return NextResponse.json( + { error: 'Authentication required' }, + { status: 401 } + ); + } + + const { id: botId, mid: mentionId } = await params; + + // Verify bot exists and user owns it + const bot = await db.query.bots.findFirst({ + where: eq(bots.id, botId), + columns: { + id: true, + userId: true, + isSuspended: true, + }, + }); + + if (!bot) { + return NextResponse.json( + { error: 'Bot not found' }, + { status: 404 } + ); + } + + if (bot.userId !== session.user.id) { + return NextResponse.json( + { error: 'Not authorized to access this bot' }, + { status: 403 } + ); + } + + if (bot.isSuspended) { + return NextResponse.json( + { error: 'Bot is suspended' }, + { status: 403 } + ); + } + + // Verify mention exists and belongs to this bot + const mention = await getMentionById(mentionId); + + if (!mention) { + return NextResponse.json( + { error: 'Mention not found' }, + { status: 404 } + ); + } + + if (mention.botId !== botId) { + return NextResponse.json( + { error: 'Mention does not belong to this bot' }, + { status: 400 } + ); + } + + // Process the mention + const result = await processMention(mentionId); + + if (!result.success) { + // Check if it's a rate limit error + if (result.error?.includes('rate limit') || result.error?.includes('Rate limit')) { + return NextResponse.json( + { error: result.error }, + { status: 429 } + ); + } + + return NextResponse.json( + { error: result.error || 'Failed to process mention' }, + { status: 500 } + ); + } + + return NextResponse.json({ + success: true, + responsePostId: result.responsePostId, + message: 'Reply posted successfully', + }); + } catch (error) { + console.error('Error responding to mention:', error); + return NextResponse.json( + { error: 'Failed to respond to mention' }, + { status: 500 } + ); + } +} diff --git a/src/app/api/bots/[id]/mentions/route.ts b/src/app/api/bots/[id]/mentions/route.ts new file mode 100644 index 0000000..a5a11df --- /dev/null +++ b/src/app/api/bots/[id]/mentions/route.ts @@ -0,0 +1,85 @@ +/** + * Bot Mentions API Routes + * + * GET /api/bots/[id]/mentions - Get pending mentions for a bot + * + * Requirements: 7.1 + */ + +import { NextRequest, NextResponse } from 'next/server'; +import { getSession } from '@/lib/auth'; +import { db, bots } from '@/db'; +import { eq } from 'drizzle-orm'; +import { getUnprocessedMentions, getAllMentions } from '@/lib/bots/mentionHandler'; + +/** + * GET /api/bots/[id]/mentions + * + * Get mentions for a bot. Returns unprocessed mentions by default, + * or all mentions if ?all=true is provided. + * + * Query Parameters: + * - all: boolean - If true, return all mentions (processed and unprocessed) + * + * Validates: Requirements 7.1 + */ +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + // Check authentication + const session = await getSession(); + if (!session?.user?.id) { + return NextResponse.json( + { error: 'Authentication required' }, + { status: 401 } + ); + } + + const { id: botId } = await params; + + // Verify bot exists and user owns it + const bot = await db.query.bots.findFirst({ + where: eq(bots.id, botId), + columns: { + id: true, + userId: true, + }, + }); + + if (!bot) { + return NextResponse.json( + { error: 'Bot not found' }, + { status: 404 } + ); + } + + if (bot.userId !== session.user.id) { + return NextResponse.json( + { error: 'Not authorized to access this bot' }, + { status: 403 } + ); + } + + // Get mentions based on query parameter + const { searchParams } = new URL(request.url); + const showAll = searchParams.get('all') === 'true'; + + const mentions = showAll + ? await getAllMentions(botId) + : await getUnprocessedMentions(botId); + + return NextResponse.json({ + mentions, + count: mentions.length, + unprocessedOnly: !showAll, + }); + } catch (error) { + console.error('Error fetching bot mentions:', error); + return NextResponse.json( + { error: 'Failed to fetch mentions' }, + { status: 500 } + ); + } +} diff --git a/src/app/api/bots/[id]/post/route.test.ts b/src/app/api/bots/[id]/post/route.test.ts new file mode 100644 index 0000000..74fa4bb --- /dev/null +++ b/src/app/api/bots/[id]/post/route.test.ts @@ -0,0 +1,369 @@ +/** + * Bot Operations API Route Tests + * + * Tests for POST /api/bots/[id]/post + * + * Requirements: 5.4 + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { POST } from './route'; +import * as auth from '@/lib/auth'; +import * as botManager from '@/lib/bots/botManager'; +import * as posting from '@/lib/bots/posting'; + +// Mock modules +vi.mock('@/lib/auth'); +vi.mock('@/lib/bots/botManager'); +vi.mock('@/lib/bots/posting'); + +describe('POST /api/bots/[id]/post', () => { + const mockUser = { + id: 'user-123', + handle: 'testuser', + email: 'test@example.com', + }; + + const mockBot = { + id: 'bot-123', + userId: 'user-123', + name: 'Test Bot', + handle: 'testbot', + isActive: true, + isSuspended: false, + }; + + const mockPost = { + id: 'post-123', + userId: 'user-123', + content: 'Test post content', + apId: 'https://example.com/posts/post-123', + apUrl: 'https://example.com/posts/post-123', + createdAt: new Date(), + }; + + const mockContentItem = { + id: '550e8400-e29b-41d4-a716-446655440000', + sourceId: '550e8400-e29b-41d4-a716-446655440001', + title: 'Test Article', + content: 'Test content', + url: 'https://example.com/article', + publishedAt: new Date(), + }; + + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(auth.requireAuth).mockResolvedValue(mockUser as any); + }); + + it('should successfully trigger a post', async () => { + // Mock ownership check + vi.mocked(botManager.userOwnsBot).mockResolvedValue(true); + + // Mock successful post creation + vi.mocked(posting.triggerPost).mockResolvedValue({ + success: true, + post: mockPost as any, + contentItem: mockContentItem, + }); + + // Create request + const request = new Request('http://localhost/api/bots/bot-123/post', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({}), + }); + + const context = { + params: Promise.resolve({ id: 'bot-123' }), + }; + + // Call the route + const response = await POST(request, context); + const data = await response.json(); + + // Verify response + expect(response.status).toBe(200); + expect(data.success).toBe(true); + expect(data.post).toBeDefined(); + expect(data.post.id).toBe('post-123'); + expect(data.contentItem).toBeDefined(); + expect(data.contentItem.id).toBe('550e8400-e29b-41d4-a716-446655440000'); + + // Verify triggerPost was called correctly + expect(posting.triggerPost).toHaveBeenCalledWith('bot-123', { + sourceContentId: undefined, + context: undefined, + }); + }); + + it('should trigger a post with specific content ID', async () => { + // Mock ownership check + vi.mocked(botManager.userOwnsBot).mockResolvedValue(true); + + // Mock successful post creation + vi.mocked(posting.triggerPost).mockResolvedValue({ + success: true, + post: mockPost as any, + contentItem: mockContentItem, + }); + + // Create request with content ID + const request = new Request('http://localhost/api/bots/bot-123/post', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + sourceContentId: '550e8400-e29b-41d4-a716-446655440000', + context: 'Test context', + }), + }); + + const context = { + params: Promise.resolve({ id: 'bot-123' }), + }; + + // Call the route + const response = await POST(request, context); + const data = await response.json(); + + // Verify response + expect(response.status).toBe(200); + expect(data.success).toBe(true); + + // Verify triggerPost was called with correct options + expect(posting.triggerPost).toHaveBeenCalledWith('bot-123', { + sourceContentId: '550e8400-e29b-41d4-a716-446655440000', + context: 'Test context', + }); + }); + + it('should return 401 if not authenticated', async () => { + // Mock authentication failure + vi.mocked(auth.requireAuth).mockRejectedValue(new Error('Authentication required')); + + // Create request + const request = new Request('http://localhost/api/bots/bot-123/post', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({}), + }); + + const context = { + params: Promise.resolve({ id: 'bot-123' }), + }; + + // Call the route + const response = await POST(request, context); + const data = await response.json(); + + // Verify response + expect(response.status).toBe(401); + expect(data.error).toBe('Authentication required'); + }); + + it('should return 403 if user does not own the bot', async () => { + // Mock ownership check - user does not own bot + vi.mocked(botManager.userOwnsBot).mockResolvedValue(false); + vi.mocked(botManager.getBotById).mockResolvedValue(mockBot as any); + + // Create request + const request = new Request('http://localhost/api/bots/bot-123/post', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({}), + }); + + const context = { + params: Promise.resolve({ id: 'bot-123' }), + }; + + // Call the route + const response = await POST(request, context); + const data = await response.json(); + + // Verify response + expect(response.status).toBe(403); + expect(data.error).toBe('Not authorized'); + }); + + it('should return 404 if bot does not exist', async () => { + // Mock ownership check - bot not found + vi.mocked(botManager.userOwnsBot).mockResolvedValue(false); + vi.mocked(botManager.getBotById).mockResolvedValue(null); + + // Create request + const request = new Request('http://localhost/api/bots/bot-123/post', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({}), + }); + + const context = { + params: Promise.resolve({ id: 'bot-123' }), + }; + + // Call the route + const response = await POST(request, context); + const data = await response.json(); + + // Verify response + expect(response.status).toBe(404); + expect(data.error).toBe('Bot not found'); + }); + + it('should return 429 if rate limited', async () => { + // Mock ownership check + vi.mocked(botManager.userOwnsBot).mockResolvedValue(true); + + // Mock rate limit error + vi.mocked(posting.triggerPost).mockResolvedValue({ + success: false, + error: 'Rate limit exceeded', + errorCode: 'RATE_LIMITED', + }); + + // Create request + const request = new Request('http://localhost/api/bots/bot-123/post', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({}), + }); + + const context = { + params: Promise.resolve({ id: 'bot-123' }), + }; + + // Call the route + const response = await POST(request, context); + const data = await response.json(); + + // Verify response + expect(response.status).toBe(429); + expect(data.success).toBe(false); + expect(data.error).toBe('Rate limit exceeded'); + expect(data.errorCode).toBe('RATE_LIMITED'); + }); + + it('should return 403 if bot is suspended', async () => { + // Mock ownership check + vi.mocked(botManager.userOwnsBot).mockResolvedValue(true); + + // Mock suspended bot error + vi.mocked(posting.triggerPost).mockResolvedValue({ + success: false, + error: 'Bot is suspended: Violation of terms', + errorCode: 'BOT_SUSPENDED', + }); + + // Create request + const request = new Request('http://localhost/api/bots/bot-123/post', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({}), + }); + + const context = { + params: Promise.resolve({ id: 'bot-123' }), + }; + + // Call the route + const response = await POST(request, context); + const data = await response.json(); + + // Verify response + expect(response.status).toBe(403); + expect(data.success).toBe(false); + expect(data.errorCode).toBe('BOT_SUSPENDED'); + }); + + it('should return 422 if no content available', async () => { + // Mock ownership check + vi.mocked(botManager.userOwnsBot).mockResolvedValue(true); + + // Mock no content error + vi.mocked(posting.triggerPost).mockResolvedValue({ + success: false, + error: 'No content available for posting', + errorCode: 'NO_CONTENT', + }); + + // Create request + const request = new Request('http://localhost/api/bots/bot-123/post', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({}), + }); + + const context = { + params: Promise.resolve({ id: 'bot-123' }), + }; + + // Call the route + const response = await POST(request, context); + const data = await response.json(); + + // Verify response + expect(response.status).toBe(422); + expect(data.success).toBe(false); + expect(data.errorCode).toBe('NO_CONTENT'); + }); + + it('should return 400 for invalid input', async () => { + // Mock ownership check + vi.mocked(botManager.userOwnsBot).mockResolvedValue(true); + + // Create request with invalid UUID + const request = new Request('http://localhost/api/bots/bot-123/post', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + sourceContentId: 'invalid-uuid', + }), + }); + + const context = { + params: Promise.resolve({ id: 'bot-123' }), + }; + + // Call the route + const response = await POST(request, context); + const data = await response.json(); + + // Verify response + expect(response.status).toBe(400); + expect(data.error).toBe('Invalid input'); + expect(data.details).toBeDefined(); + }); + + it('should return 500 for generation failures', async () => { + // Mock ownership check + vi.mocked(botManager.userOwnsBot).mockResolvedValue(true); + + // Mock generation failure + vi.mocked(posting.triggerPost).mockResolvedValue({ + success: false, + error: 'Failed to generate post content', + errorCode: 'GENERATION_FAILED', + }); + + // Create request + const request = new Request('http://localhost/api/bots/bot-123/post', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({}), + }); + + const context = { + params: Promise.resolve({ id: 'bot-123' }), + }; + + // Call the route + const response = await POST(request, context); + const data = await response.json(); + + // Verify response + expect(response.status).toBe(500); + expect(data.success).toBe(false); + expect(data.errorCode).toBe('GENERATION_FAILED'); + }); +}); diff --git a/src/app/api/bots/[id]/post/route.ts b/src/app/api/bots/[id]/post/route.ts new file mode 100644 index 0000000..9ce059a --- /dev/null +++ b/src/app/api/bots/[id]/post/route.ts @@ -0,0 +1,170 @@ +/** + * Bot Operations API Routes + * + * POST /api/bots/[id]/post - Manual post trigger + * + * Requirements: 5.4 + */ + +import { NextResponse } from 'next/server'; +import { requireAuth } from '@/lib/auth'; +import { z } from 'zod'; +import { + getBotById, + userOwnsBot, + BotNotFoundError, +} from '@/lib/bots/botManager'; +import { + triggerPost, + type TriggerPostOptions, + type PostCreationErrorCode, +} from '@/lib/bots/posting'; + +type RouteContext = { params: Promise<{ id: string }> }; + +// Schema for manual post trigger +const triggerPostSchema = z.object({ + sourceContentId: z.string().uuid().optional(), + context: z.string().max(1000).optional(), +}); + +/** + * POST /api/bots/[id]/post - Manually trigger a post + * + * Requires authentication. + * Triggers a post for the bot if the user owns the bot. + * + * Request body: + * - sourceContentId (optional): Specific content item ID to post about + * - context (optional): Additional context for the post + * + * Response: + * - success: Whether the post was created successfully + * - post: The created post (if successful) + * - error: Error message (if failed) + * - errorCode: Error code (if failed) + * + * Validates: Requirements 5.4 + */ +export async function POST(request: Request, context: RouteContext) { + try { + const user = await requireAuth(); + const { id } = await context.params; + + // Parse request body + const body = await request.json(); + const data = triggerPostSchema.parse(body); + + // Check if user owns the bot + const isOwner = await userOwnsBot(user.id, id); + if (!isOwner) { + // Check if bot exists at all + const bot = await getBotById(id); + if (!bot) { + return NextResponse.json({ error: 'Bot not found' }, { status: 404 }); + } + return NextResponse.json({ error: 'Not authorized' }, { status: 403 }); + } + + // Build trigger options + const options: TriggerPostOptions = { + sourceContentId: data.sourceContentId, + context: data.context, + }; + + // Trigger the post + const result = await triggerPost(id, options); + + // Handle result + if (!result.success) { + // Map error codes to HTTP status codes + const statusCode = getStatusCodeForError(result.errorCode); + + return NextResponse.json( + { + success: false, + error: result.error, + errorCode: result.errorCode, + }, + { status: statusCode } + ); + } + + // Return success response + return NextResponse.json({ + success: true, + post: { + id: result.post!.id, + userId: result.post!.userId, + content: result.post!.content, + apId: result.post!.apId, + apUrl: result.post!.apUrl, + createdAt: result.post!.createdAt, + }, + contentItem: result.contentItem ? { + id: result.contentItem.id, + title: result.contentItem.title, + url: result.contentItem.url, + } : undefined, + }); + } catch (error) { + console.error('Trigger post error:', error); + + if (error instanceof z.ZodError) { + return NextResponse.json( + { error: 'Invalid input', details: error.issues }, + { status: 400 } + ); + } + + if (error instanceof Error && error.message === 'Authentication required') { + return NextResponse.json({ error: 'Authentication required' }, { status: 401 }); + } + + if (error instanceof BotNotFoundError) { + return NextResponse.json( + { error: error.message, code: error.code }, + { status: 404 } + ); + } + + return NextResponse.json( + { error: 'Failed to trigger post' }, + { status: 500 } + ); + } +} + +/** + * Map error codes to HTTP status codes. + * + * @param errorCode - The error code from post creation + * @returns HTTP status code + */ +function getStatusCodeForError(errorCode?: PostCreationErrorCode): number { + switch (errorCode) { + case 'BOT_NOT_FOUND': + case 'CONTENT_NOT_FOUND': + return 404; + + case 'BOT_SUSPENDED': + case 'BOT_INACTIVE': + return 403; + + case 'NO_API_KEY': + case 'VALIDATION_FAILED': + return 400; + + case 'RATE_LIMITED': + return 429; + + case 'NO_CONTENT': + return 422; // Unprocessable Entity + + case 'GENERATION_FAILED': + case 'DATABASE_ERROR': + case 'FEDERATION_ERROR': + default: + return 500; + } +} diff --git a/src/app/api/bots/[id]/reinstate/route.ts b/src/app/api/bots/[id]/reinstate/route.ts new file mode 100644 index 0000000..2f42df4 --- /dev/null +++ b/src/app/api/bots/[id]/reinstate/route.ts @@ -0,0 +1,62 @@ +/** + * Bot Reinstatement API Route + * + * POST /api/bots/[id]/reinstate - Reinstate a suspended bot (admin only) + * + * Requirements: 10.6 + */ + +import { NextRequest, NextResponse } from 'next/server'; +import { getSession } from '@/lib/auth'; +import { db, bots } from '@/db'; +import { eq } from 'drizzle-orm'; +import { reinstateBot } from '@/lib/bots/suspension'; + +export async function POST( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + const session = await getSession(); + if (!session?.user?.id) { + return NextResponse.json( + { error: 'Authentication required' }, + { status: 401 } + ); + } + + // TODO: Add admin check here + + const { id: botId } = await params; + + // Verify bot exists + const bot = await db.query.bots.findFirst({ + where: eq(bots.id, botId), + columns: { id: true, userId: true }, + }); + + if (!bot) { + return NextResponse.json( + { error: 'Bot not found' }, + { status: 404 } + ); + } + + // Reinstate the bot + const reinstatedBot = await reinstateBot(botId); + + return NextResponse.json({ + success: true, + bot: { + id: reinstatedBot.id, + isSuspended: reinstatedBot.isSuspended, + }, + }); + } catch (error) { + console.error('Error reinstating bot:', error); + return NextResponse.json( + { error: 'Failed to reinstate bot' }, + { status: 500 } + ); + } +} diff --git a/src/app/api/bots/[id]/route.ts b/src/app/api/bots/[id]/route.ts new file mode 100644 index 0000000..e2eefbc --- /dev/null +++ b/src/app/api/bots/[id]/route.ts @@ -0,0 +1,280 @@ +/** + * Bot Detail API Routes + * + * GET /api/bots/[id] - Get bot details + * PUT /api/bots/[id] - Update bot + * DELETE /api/bots/[id] - Delete bot + * + * Requirements: 1.1, 1.3, 1.4 + */ + +import { NextResponse } from 'next/server'; +import { requireAuth } from '@/lib/auth'; +import { z } from 'zod'; +import { + getBotById, + updateBot, + deleteBot, + userOwnsBot, + BotNotFoundError, + BotValidationError, +} from '@/lib/bots/botManager'; + +type RouteContext = { params: Promise<{ id: string }> }; + +// Schema for updating a bot +const updateBotSchema = z.object({ + name: z.string().min(1).max(100).optional(), + bio: z.string().max(500).optional().nullable(), + avatarUrl: z.string().url().optional().nullable(), + headerUrl: z.string().url().optional().nullable(), + personality: z.object({ + systemPrompt: z.string().min(1).max(10000), + temperature: z.number().min(0).max(2), + maxTokens: z.number().int().min(1).max(100000), + responseStyle: z.string().optional(), + }).optional(), + llmProvider: z.enum(['openrouter', 'openai', 'anthropic']).optional(), + llmModel: z.string().min(1).optional(), + llmApiKey: z.string().min(1).optional(), + schedule: z.object({ + type: z.enum(['interval', 'times', 'cron']), + intervalMinutes: z.number().int().min(5).optional(), + times: z.array(z.string().regex(/^([01][0-9]|2[0-3]):[0-5][0-9]$/)).optional(), + cronExpression: z.string().optional(), + timezone: z.string().optional(), + }).optional().nullable(), + autonomousMode: z.boolean().optional(), + isActive: z.boolean().optional(), +}); + +/** + * GET /api/bots/[id] - Get bot details + * + * Requires authentication. + * Returns the bot details if the user owns the bot. + * + * Validates: Requirements 1.3 + */ +export async function GET(request: Request, context: RouteContext) { + try { + const user = await requireAuth(); + const { id } = await context.params; + + // Check if user owns the bot + const isOwner = await userOwnsBot(user.id, id); + if (!isOwner) { + // Check if bot exists at all + const bot = await getBotById(id); + if (!bot) { + return NextResponse.json({ error: 'Bot not found' }, { status: 404 }); + } + return NextResponse.json({ error: 'Not authorized' }, { status: 403 }); + } + + const bot = await getBotById(id); + if (!bot) { + return NextResponse.json({ error: 'Bot not found' }, { status: 404 }); + } + + // Return bot without sensitive data (API keys) + return NextResponse.json({ + success: true, + bot: { + id: bot.id, + userId: bot.userId, + name: bot.name, + handle: bot.handle, + bio: bot.bio, + avatarUrl: bot.avatarUrl, + headerUrl: bot.headerUrl, + personalityConfig: bot.personalityConfig, + llmProvider: bot.llmProvider, + llmModel: bot.llmModel, + scheduleConfig: bot.scheduleConfig, + autonomousMode: bot.autonomousMode, + isActive: bot.isActive, + isSuspended: bot.isSuspended, + suspensionReason: bot.suspensionReason, + suspendedAt: bot.suspendedAt, + publicKey: bot.publicKey, + lastPostAt: bot.lastPostAt, + createdAt: bot.createdAt, + updatedAt: bot.updatedAt, + }, + }); + } catch (error) { + console.error('Get bot error:', error); + + if (error instanceof Error && error.message === 'Authentication required') { + return NextResponse.json({ error: 'Authentication required' }, { status: 401 }); + } + + return NextResponse.json( + { error: 'Failed to get bot' }, + { status: 500 } + ); + } +} + +/** + * PUT /api/bots/[id] - Update bot (full update) + * PATCH /api/bots/[id] - Update bot (partial update) + * + * Requires authentication. + * Updates the bot configuration if the user owns the bot. + * + * Validates: Requirements 1.1 + */ +export async function PUT(request: Request, context: RouteContext) { + return handleUpdate(request, context); +} + +export async function PATCH(request: Request, context: RouteContext) { + return handleUpdate(request, context); +} + +async function handleUpdate(request: Request, context: RouteContext) { + try { + const user = await requireAuth(); + const { id } = await context.params; + const body = await request.json(); + const data = updateBotSchema.parse(body); + + // Check if user owns the bot + const isOwner = await userOwnsBot(user.id, id); + if (!isOwner) { + // Check if bot exists at all + const bot = await getBotById(id); + if (!bot) { + return NextResponse.json({ error: 'Bot not found' }, { status: 404 }); + } + return NextResponse.json({ error: 'Not authorized' }, { status: 403 }); + } + + // Build update input + const updateInput: Parameters[1] = {}; + + if (data.name !== undefined) updateInput.name = data.name; + if (data.bio !== undefined) updateInput.bio = data.bio ?? undefined; + if (data.avatarUrl !== undefined) updateInput.avatarUrl = data.avatarUrl ?? undefined; + if (data.headerUrl !== undefined) updateInput.headerUrl = data.headerUrl ?? undefined; + if (data.personality !== undefined) updateInput.personality = data.personality; + if (data.llmProvider !== undefined) updateInput.llmProvider = data.llmProvider; + if (data.llmModel !== undefined) updateInput.llmModel = data.llmModel; + if (data.llmApiKey !== undefined) updateInput.llmApiKey = data.llmApiKey; + if (data.schedule !== undefined) updateInput.schedule = data.schedule; + if (data.autonomousMode !== undefined) updateInput.autonomousMode = data.autonomousMode; + if (data.isActive !== undefined) updateInput.isActive = data.isActive; + + const updatedBot = await updateBot(id, updateInput); + + // Return updated bot without sensitive data + return NextResponse.json({ + success: true, + bot: { + id: updatedBot.id, + userId: updatedBot.userId, + name: updatedBot.name, + handle: updatedBot.handle, + bio: updatedBot.bio, + avatarUrl: updatedBot.avatarUrl, + headerUrl: updatedBot.headerUrl, + personalityConfig: updatedBot.personalityConfig, + llmProvider: updatedBot.llmProvider, + llmModel: updatedBot.llmModel, + scheduleConfig: updatedBot.scheduleConfig, + autonomousMode: updatedBot.autonomousMode, + isActive: updatedBot.isActive, + isSuspended: updatedBot.isSuspended, + suspensionReason: updatedBot.suspensionReason, + lastPostAt: updatedBot.lastPostAt, + createdAt: updatedBot.createdAt, + updatedAt: updatedBot.updatedAt, + }, + }); + } catch (error) { + console.error('Update bot error:', error); + + if (error instanceof z.ZodError) { + return NextResponse.json( + { error: 'Invalid input', details: error.issues }, + { status: 400 } + ); + } + + if (error instanceof Error && error.message === 'Authentication required') { + return NextResponse.json({ error: 'Authentication required' }, { status: 401 }); + } + + if (error instanceof BotNotFoundError) { + return NextResponse.json( + { error: error.message, code: error.code }, + { status: 404 } + ); + } + + if (error instanceof BotValidationError) { + return NextResponse.json( + { error: error.message, code: error.code }, + { status: 400 } + ); + } + + return NextResponse.json( + { error: 'Failed to update bot' }, + { status: 500 } + ); + } +} + +/** + * DELETE /api/bots/[id] - Delete bot + * + * Requires authentication. + * Deletes the bot and all associated data if the user owns the bot. + * + * Validates: Requirements 1.4 + */ +export async function DELETE(request: Request, context: RouteContext) { + try { + const user = await requireAuth(); + const { id } = await context.params; + + // Check if user owns the bot + const isOwner = await userOwnsBot(user.id, id); + if (!isOwner) { + // Check if bot exists at all + const bot = await getBotById(id); + if (!bot) { + return NextResponse.json({ error: 'Bot not found' }, { status: 404 }); + } + return NextResponse.json({ error: 'Not authorized' }, { status: 403 }); + } + + await deleteBot(id); + + return NextResponse.json({ + success: true, + message: 'Bot deleted successfully', + }); + } catch (error) { + console.error('Delete bot error:', error); + + if (error instanceof Error && error.message === 'Authentication required') { + return NextResponse.json({ error: 'Authentication required' }, { status: 401 }); + } + + if (error instanceof BotNotFoundError) { + return NextResponse.json( + { error: error.message, code: error.code }, + { status: 404 } + ); + } + + return NextResponse.json( + { error: 'Failed to delete bot' }, + { status: 500 } + ); + } +} diff --git a/src/app/api/bots/[id]/sources/[sid]/fetch/route.ts b/src/app/api/bots/[id]/sources/[sid]/fetch/route.ts new file mode 100644 index 0000000..d0e8e77 --- /dev/null +++ b/src/app/api/bots/[id]/sources/[sid]/fetch/route.ts @@ -0,0 +1,117 @@ +/** + * Content Source Fetch API Route + * + * POST /api/bots/[id]/sources/[sid]/fetch - Manually trigger fetch + * + * Requirements: 4.1, 4.6 + */ + +import { NextResponse } from 'next/server'; +import { requireAuth } from '@/lib/auth'; +import { userOwnsBot, getBotById } from '@/lib/bots/botManager'; +import { + getSourceById, + botOwnsSource, + ContentSourceNotFoundError, +} from '@/lib/bots/contentSource'; +import { + fetchContentWithRetry, + FetchResult, +} from '@/lib/bots/contentFetcher'; + +type RouteContext = { params: Promise<{ id: string; sid: string }> }; + +/** + * POST /api/bots/[id]/sources/[sid]/fetch - Manually trigger fetch + * + * Requires authentication. + * Triggers a manual content fetch for the source if the user owns the bot. + * + * Validates: Requirements 4.1, 4.6 + */ +export async function POST(_request: Request, context: RouteContext) { + try { + const user = await requireAuth(); + const { id: botId, sid: sourceId } = await context.params; + + // Check if user owns the bot + const isOwner = await userOwnsBot(user.id, botId); + if (!isOwner) { + // Check if bot exists at all + const bot = await getBotById(botId); + if (!bot) { + return NextResponse.json({ error: 'Bot not found' }, { status: 404 }); + } + return NextResponse.json({ error: 'Not authorized' }, { status: 403 }); + } + + // Check if the source belongs to this bot + const sourceOwned = await botOwnsSource(botId, sourceId); + if (!sourceOwned) { + // Check if source exists at all + const source = await getSourceById(sourceId); + if (!source) { + return NextResponse.json({ error: 'Content source not found' }, { status: 404 }); + } + return NextResponse.json({ error: 'Not authorized' }, { status: 403 }); + } + + // Get the source to check if it's active + const source = await getSourceById(sourceId); + if (!source) { + return NextResponse.json({ error: 'Content source not found' }, { status: 404 }); + } + + // Trigger the fetch with retry logic + const result: FetchResult = await fetchContentWithRetry(sourceId, 3, { + maxItems: 50, + timeout: 30000, + }); + + // Get updated source state + const updatedSource = await getSourceById(sourceId); + + return NextResponse.json({ + success: result.success, + result: { + sourceId: result.sourceId, + itemsFetched: result.itemsFetched, + itemsStored: result.itemsStored, + error: result.error, + warnings: result.warnings, + }, + source: updatedSource ? { + id: updatedSource.id, + botId: updatedSource.botId, + type: updatedSource.type, + url: updatedSource.url, + subreddit: updatedSource.subreddit, + keywords: updatedSource.keywords, + isActive: updatedSource.isActive, + lastFetchAt: updatedSource.lastFetchAt, + lastError: updatedSource.lastError, + consecutiveErrors: updatedSource.consecutiveErrors, + createdAt: updatedSource.createdAt, + updatedAt: updatedSource.updatedAt, + } : null, + }); + } catch (error) { + console.error('Manual fetch error:', error); + + if (error instanceof Error && error.message === 'Authentication required') { + return NextResponse.json({ error: 'Authentication required' }, { status: 401 }); + } + + if (error instanceof ContentSourceNotFoundError) { + return NextResponse.json( + { error: error.message, code: error.code }, + { status: 404 } + ); + } + + return NextResponse.json( + { error: 'Failed to fetch content' }, + { status: 500 } + ); + } +} diff --git a/src/app/api/bots/[id]/sources/[sid]/route.ts b/src/app/api/bots/[id]/sources/[sid]/route.ts new file mode 100644 index 0000000..a72e38e --- /dev/null +++ b/src/app/api/bots/[id]/sources/[sid]/route.ts @@ -0,0 +1,200 @@ +/** + * Content Source Detail API Routes + * + * PUT /api/bots/[id]/sources/[sid] - Update content source + * DELETE /api/bots/[id]/sources/[sid] - Remove content source + * + * Requirements: 4.1, 4.6 + */ + +import { NextResponse } from 'next/server'; +import { requireAuth } from '@/lib/auth'; +import { z } from 'zod'; +import { userOwnsBot, getBotById } from '@/lib/bots/botManager'; +import { + updateSource, + removeSource, + getSourceById, + botOwnsSource, + ContentSourceValidationError, + ContentSourceNotFoundError, + MAX_KEYWORDS, + MAX_KEYWORD_LENGTH, +} from '@/lib/bots/contentSource'; + +type RouteContext = { params: Promise<{ id: string; sid: string }> }; + +// Schema for updating a content source +const updateSourceSchema = z.object({ + url: z.string().url('URL must be a valid HTTP or HTTPS URL').max(2048, 'URL is too long').optional(), + keywords: z.array( + z.string() + .min(1, 'Keyword cannot be empty') + .max(MAX_KEYWORD_LENGTH, `Keyword is too long (maximum ${MAX_KEYWORD_LENGTH} characters)`) + ) + .max(MAX_KEYWORDS, `Maximum ${MAX_KEYWORDS} keywords allowed`) + .optional() + .nullable(), + isActive: z.boolean().optional(), +}); + +/** + * PUT /api/bots/[id]/sources/[sid] - Update content source + * + * Requires authentication. + * Updates the content source if the user owns the bot. + * + * Validates: Requirements 4.1 + */ +export async function PUT(request: Request, context: RouteContext) { + try { + const user = await requireAuth(); + const { id: botId, sid: sourceId } = await context.params; + const body = await request.json(); + const data = updateSourceSchema.parse(body); + + // Check if user owns the bot + const isOwner = await userOwnsBot(user.id, botId); + if (!isOwner) { + // Check if bot exists at all + const bot = await getBotById(botId); + if (!bot) { + return NextResponse.json({ error: 'Bot not found' }, { status: 404 }); + } + return NextResponse.json({ error: 'Not authorized' }, { status: 403 }); + } + + // Check if the source belongs to this bot + const sourceOwned = await botOwnsSource(botId, sourceId); + if (!sourceOwned) { + // Check if source exists at all + const source = await getSourceById(sourceId); + if (!source) { + return NextResponse.json({ error: 'Content source not found' }, { status: 404 }); + } + return NextResponse.json({ error: 'Not authorized' }, { status: 403 }); + } + + // Build update object + const updates: Parameters[1] = {}; + if (data.url !== undefined) updates.url = data.url; + if (data.keywords !== undefined) updates.keywords = data.keywords ?? undefined; + if (data.isActive !== undefined) updates.isActive = data.isActive; + + // Update the source + const updatedSource = await updateSource(sourceId, updates); + + return NextResponse.json({ + success: true, + source: { + id: updatedSource.id, + botId: updatedSource.botId, + type: updatedSource.type, + url: updatedSource.url, + subreddit: updatedSource.subreddit, + keywords: updatedSource.keywords, + isActive: updatedSource.isActive, + lastFetchAt: updatedSource.lastFetchAt, + lastError: updatedSource.lastError, + consecutiveErrors: updatedSource.consecutiveErrors, + createdAt: updatedSource.createdAt, + updatedAt: updatedSource.updatedAt, + }, + }); + } catch (error) { + console.error('Update content source error:', error); + + if (error instanceof z.ZodError) { + return NextResponse.json( + { error: 'Invalid input', details: error.issues }, + { status: 400 } + ); + } + + if (error instanceof Error && error.message === 'Authentication required') { + return NextResponse.json({ error: 'Authentication required' }, { status: 401 }); + } + + if (error instanceof ContentSourceNotFoundError) { + return NextResponse.json( + { error: error.message, code: error.code }, + { status: 404 } + ); + } + + if (error instanceof ContentSourceValidationError) { + return NextResponse.json( + { error: error.message, code: error.code, details: error.errors }, + { status: 400 } + ); + } + + return NextResponse.json( + { error: 'Failed to update content source' }, + { status: 500 } + ); + } +} + +/** + * DELETE /api/bots/[id]/sources/[sid] - Remove content source + * + * Requires authentication. + * Removes the content source if the user owns the bot. + * + * Validates: Requirements 4.1 + */ +export async function DELETE(_request: Request, context: RouteContext) { + try { + const user = await requireAuth(); + const { id: botId, sid: sourceId } = await context.params; + + // Check if user owns the bot + const isOwner = await userOwnsBot(user.id, botId); + if (!isOwner) { + // Check if bot exists at all + const bot = await getBotById(botId); + if (!bot) { + return NextResponse.json({ error: 'Bot not found' }, { status: 404 }); + } + return NextResponse.json({ error: 'Not authorized' }, { status: 403 }); + } + + // Check if the source belongs to this bot + const sourceOwned = await botOwnsSource(botId, sourceId); + if (!sourceOwned) { + // Check if source exists at all + const source = await getSourceById(sourceId); + if (!source) { + return NextResponse.json({ error: 'Content source not found' }, { status: 404 }); + } + return NextResponse.json({ error: 'Not authorized' }, { status: 403 }); + } + + // Remove the source + await removeSource(sourceId); + + return NextResponse.json({ + success: true, + message: 'Content source removed successfully', + }); + } catch (error) { + console.error('Remove content source error:', error); + + if (error instanceof Error && error.message === 'Authentication required') { + return NextResponse.json({ error: 'Authentication required' }, { status: 401 }); + } + + if (error instanceof ContentSourceNotFoundError) { + return NextResponse.json( + { error: error.message, code: error.code }, + { status: 404 } + ); + } + + return NextResponse.json( + { error: 'Failed to remove content source' }, + { status: 500 } + ); + } +} diff --git a/src/app/api/bots/[id]/sources/route.ts b/src/app/api/bots/[id]/sources/route.ts new file mode 100644 index 0000000..1975544 --- /dev/null +++ b/src/app/api/bots/[id]/sources/route.ts @@ -0,0 +1,239 @@ +/** + * Content Source API Routes + * + * POST /api/bots/[id]/sources - Add content source + * GET /api/bots/[id]/sources - List content sources + * + * Requirements: 4.1, 4.6 + */ + +import { NextResponse } from 'next/server'; +import { requireAuth } from '@/lib/auth'; +import { z } from 'zod'; +import { userOwnsBot, getBotById } from '@/lib/bots/botManager'; +import { + addSource, + getSourcesByBot, + ContentSourceValidationError, + BotNotFoundError, + SUPPORTED_SOURCE_TYPES, + MAX_KEYWORDS, + MAX_KEYWORD_LENGTH, +} from '@/lib/bots/contentSource'; + +type RouteContext = { params: Promise<{ id: string }> }; + +// Schema for Brave News config +const braveNewsConfigSchema = z.object({ + query: z.string().min(1, 'Search query is required'), + freshness: z.enum(['pd', 'pw', 'pm', 'py']).optional(), + country: z.string().length(2, 'Country must be a 2-letter ISO code').optional(), + searchLang: z.string().optional(), + count: z.number().min(1).max(50).optional(), +}).optional(); + +// Schema for News API config +const newsApiConfigSchema = z.object({ + provider: z.enum(['newsapi', 'gnews', 'newsdata']), + query: z.string().min(1, 'Search query is required'), + category: z.string().optional(), + country: z.string().optional(), + language: z.string().optional(), +}).optional(); + +// Schema for adding a content source +const addSourceSchema = z.object({ + type: z.enum(['rss', 'reddit', 'news_api', 'brave_news', 'youtube'], { + message: `Source type must be one of: ${SUPPORTED_SOURCE_TYPES.join(', ')}`, + }), + url: z.string().url('URL must be a valid HTTP or HTTPS URL').max(2048, 'URL is too long'), + subreddit: z.string() + .regex(/^[a-zA-Z0-9_]{3,21}$/, 'Subreddit name must be 3-21 characters, alphanumeric and underscores only') + .optional(), + apiKey: z.string().min(10, 'API key is too short').max(256, 'API key is too long').optional(), + keywords: z.array( + z.string() + .min(1, 'Keyword cannot be empty') + .max(MAX_KEYWORD_LENGTH, `Keyword is too long (maximum ${MAX_KEYWORD_LENGTH} characters)`) + ) + .max(MAX_KEYWORDS, `Maximum ${MAX_KEYWORDS} keywords allowed`) + .optional(), + braveNewsConfig: braveNewsConfigSchema, + newsApiConfig: newsApiConfigSchema, +}).refine( + (data) => { + // Reddit sources require subreddit + if (data.type === 'reddit' && !data.subreddit) { + return false; + } + return true; + }, + { message: 'Subreddit name is required for Reddit sources', path: ['subreddit'] } +).refine( + (data) => { + // News API and Brave News sources require apiKey + if ((data.type === 'news_api' || data.type === 'brave_news') && !data.apiKey) { + return false; + } + return true; + }, + { message: 'API key is required for news API sources', path: ['apiKey'] } +).refine( + (data) => { + // Brave News sources require braveNewsConfig with query + if (data.type === 'brave_news' && !data.braveNewsConfig?.query) { + return false; + } + return true; + }, + { message: 'Search query is required for Brave News sources', path: ['braveNewsConfig'] } +); + +/** + * POST /api/bots/[id]/sources - Add content source + * + * Requires authentication. + * Adds a new content source to the bot if the user owns the bot. + * + * Validates: Requirements 4.1, 4.6 + */ +export async function POST(request: Request, context: RouteContext) { + try { + const user = await requireAuth(); + const { id: botId } = await context.params; + const body = await request.json(); + const data = addSourceSchema.parse(body); + + // Check if user owns the bot + const isOwner = await userOwnsBot(user.id, botId); + if (!isOwner) { + // Check if bot exists at all + const bot = await getBotById(botId); + if (!bot) { + return NextResponse.json({ error: 'Bot not found' }, { status: 404 }); + } + return NextResponse.json({ error: 'Not authorized' }, { status: 403 }); + } + + // Add the content source + const source = await addSource(botId, { + type: data.type, + url: data.url, + subreddit: data.subreddit, + apiKey: data.apiKey, + keywords: data.keywords, + braveNewsConfig: data.braveNewsConfig, + newsApiConfig: data.newsApiConfig, + }); + + return NextResponse.json({ + success: true, + source: { + id: source.id, + botId: source.botId, + type: source.type, + url: source.url, + subreddit: source.subreddit, + keywords: source.keywords, + sourceConfig: source.sourceConfig, + isActive: source.isActive, + lastFetchAt: source.lastFetchAt, + lastError: source.lastError, + consecutiveErrors: source.consecutiveErrors, + createdAt: source.createdAt, + updatedAt: source.updatedAt, + }, + }, { status: 201 }); + } catch (error) { + console.error('Add content source error:', error); + + if (error instanceof z.ZodError) { + return NextResponse.json( + { error: 'Invalid input', details: error.issues }, + { status: 400 } + ); + } + + if (error instanceof Error && error.message === 'Authentication required') { + return NextResponse.json({ error: 'Authentication required' }, { status: 401 }); + } + + if (error instanceof BotNotFoundError) { + return NextResponse.json( + { error: error.message, code: error.code }, + { status: 404 } + ); + } + + if (error instanceof ContentSourceValidationError) { + return NextResponse.json( + { error: error.message, code: error.code, details: error.errors }, + { status: 400 } + ); + } + + return NextResponse.json( + { error: 'Failed to add content source' }, + { status: 500 } + ); + } +} + +/** + * GET /api/bots/[id]/sources - List content sources + * + * Requires authentication. + * Returns all content sources for the bot if the user owns the bot. + * + * Validates: Requirements 4.6 + */ +export async function GET(_request: Request, context: RouteContext) { + try { + const user = await requireAuth(); + const { id: botId } = await context.params; + + // Check if user owns the bot + const isOwner = await userOwnsBot(user.id, botId); + if (!isOwner) { + // Check if bot exists at all + const bot = await getBotById(botId); + if (!bot) { + return NextResponse.json({ error: 'Bot not found' }, { status: 404 }); + } + return NextResponse.json({ error: 'Not authorized' }, { status: 403 }); + } + + // Get all sources for the bot + const sources = await getSourcesByBot(botId); + + return NextResponse.json({ + success: true, + sources: sources.map(source => ({ + id: source.id, + botId: source.botId, + type: source.type, + url: source.url, + subreddit: source.subreddit, + keywords: source.keywords, + sourceConfig: source.sourceConfig, + isActive: source.isActive, + lastFetchAt: source.lastFetchAt, + lastError: source.lastError, + consecutiveErrors: source.consecutiveErrors, + createdAt: source.createdAt, + updatedAt: source.updatedAt, + })), + }); + } catch (error) { + console.error('List content sources error:', error); + + if (error instanceof Error && error.message === 'Authentication required') { + return NextResponse.json({ error: 'Authentication required' }, { status: 401 }); + } + + return NextResponse.json( + { error: 'Failed to list content sources' }, + { status: 500 } + ); + } +} diff --git a/src/app/api/bots/[id]/suspend/route.ts b/src/app/api/bots/[id]/suspend/route.ts new file mode 100644 index 0000000..3ac5e3c --- /dev/null +++ b/src/app/api/bots/[id]/suspend/route.ts @@ -0,0 +1,74 @@ +/** + * Bot Suspension API Route + * + * POST /api/bots/[id]/suspend - Suspend a bot (admin only) + * + * Requirements: 10.6 + */ + +import { NextRequest, NextResponse } from 'next/server'; +import { getSession } from '@/lib/auth'; +import { db, bots } from '@/db'; +import { eq } from 'drizzle-orm'; +import { suspendBot } from '@/lib/bots/suspension'; + +export async function POST( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + const session = await getSession(); + if (!session?.user?.id) { + return NextResponse.json( + { error: 'Authentication required' }, + { status: 401 } + ); + } + + // TODO: Add admin check here + // For now, only bot owner can suspend + + const { id: botId } = await params; + const body = await request.json(); + const { reason } = body; + + if (!reason) { + return NextResponse.json( + { error: 'Suspension reason is required' }, + { status: 400 } + ); + } + + // Verify bot exists + const bot = await db.query.bots.findFirst({ + where: eq(bots.id, botId), + columns: { id: true, userId: true }, + }); + + if (!bot) { + return NextResponse.json( + { error: 'Bot not found' }, + { status: 404 } + ); + } + + // Suspend the bot + const suspendedBot = await suspendBot(botId, reason); + + return NextResponse.json({ + success: true, + bot: { + id: suspendedBot.id, + isSuspended: suspendedBot.isSuspended, + suspensionReason: suspendedBot.suspensionReason, + suspendedAt: suspendedBot.suspendedAt, + }, + }); + } catch (error) { + console.error('Error suspending bot:', error); + return NextResponse.json( + { error: 'Failed to suspend bot' }, + { status: 500 } + ); + } +} diff --git a/src/app/api/bots/route.ts b/src/app/api/bots/route.ts new file mode 100644 index 0000000..0ce4887 --- /dev/null +++ b/src/app/api/bots/route.ts @@ -0,0 +1,189 @@ +/** + * Bot API Routes + * + * POST /api/bots - Create a new bot + * GET /api/bots - List user's bots + * + * Requirements: 1.1, 1.3 + */ + +import { NextResponse } from 'next/server'; +import { requireAuth } from '@/lib/auth'; +import { z } from 'zod'; +import { + createBot, + getBotsByUser, + BotLimitExceededError, + BotHandleTakenError, + BotValidationError, +} from '@/lib/bots/botManager'; + +// Schema for creating a bot +const createBotSchema = z.object({ + name: z.string().min(1).max(100), + handle: z.string().min(3).max(30).regex(/^[a-zA-Z0-9_]+$/, 'Handle must be alphanumeric and underscores only'), + bio: z.string().max(500).optional(), + avatarUrl: z.string().url().optional(), + headerUrl: z.string().url().optional(), + personality: z.object({ + systemPrompt: z.string().min(1).max(10000), + temperature: z.number().min(0).max(2), + maxTokens: z.number().int().min(1).max(100000), + responseStyle: z.string().optional(), + }), + llmProvider: z.enum(['openrouter', 'openai', 'anthropic']), + llmModel: z.string().min(1), + llmApiKey: z.string().min(1), + schedule: z.object({ + type: z.enum(['interval', 'times', 'cron']), + intervalMinutes: z.number().int().min(5).optional(), + times: z.array(z.string().regex(/^([01][0-9]|2[0-3]):[0-5][0-9]$/)).optional(), + cronExpression: z.string().optional(), + timezone: z.string().optional(), + }).optional(), + autonomousMode: z.boolean().optional(), +}); + +/** + * POST /api/bots - Create a new bot + * + * Requires authentication. + * Creates a new bot linked to the authenticated user's account. + * + * Validates: Requirements 1.1, 1.2 + */ +export async function POST(request: Request) { + try { + const user = await requireAuth(); + const body = await request.json(); + const data = createBotSchema.parse(body); + + const bot = await createBot(user.id, { + name: data.name, + handle: data.handle, + bio: data.bio, + avatarUrl: data.avatarUrl, + headerUrl: data.headerUrl, + personality: data.personality, + llmProvider: data.llmProvider, + llmModel: data.llmModel, + llmApiKey: data.llmApiKey, + schedule: data.schedule, + autonomousMode: data.autonomousMode, + }); + + // Return bot without sensitive data + return NextResponse.json({ + success: true, + bot: { + id: bot.id, + userId: bot.userId, + name: bot.name, + handle: bot.handle, + bio: bot.bio, + avatarUrl: bot.avatarUrl, + personalityConfig: bot.personalityConfig, + llmProvider: bot.llmProvider, + llmModel: bot.llmModel, + scheduleConfig: bot.scheduleConfig, + autonomousMode: bot.autonomousMode, + isActive: bot.isActive, + isSuspended: bot.isSuspended, + lastPostAt: bot.lastPostAt, + createdAt: bot.createdAt, + updatedAt: bot.updatedAt, + }, + }, { status: 201 }); + } catch (error) { + console.error('Create bot error:', error); + + if (error instanceof z.ZodError) { + return NextResponse.json( + { error: 'Invalid input', details: error.issues }, + { status: 400 } + ); + } + + if (error instanceof Error && error.message === 'Authentication required') { + return NextResponse.json({ error: 'Authentication required' }, { status: 401 }); + } + + if (error instanceof BotLimitExceededError) { + return NextResponse.json( + { error: error.message, code: error.code }, + { status: 403 } + ); + } + + if (error instanceof BotHandleTakenError) { + return NextResponse.json( + { error: error.message, code: error.code }, + { status: 409 } + ); + } + + if (error instanceof BotValidationError) { + return NextResponse.json( + { error: error.message, code: error.code }, + { status: 400 } + ); + } + + return NextResponse.json( + { error: 'Failed to create bot' }, + { status: 500 } + ); + } +} + +/** + * GET /api/bots - List user's bots + * + * Requires authentication. + * Returns all bots belonging to the authenticated user. + * + * Validates: Requirements 1.3 + */ +export async function GET() { + try { + const user = await requireAuth(); + const userBots = await getBotsByUser(user.id); + + // Return bots without sensitive data + const sanitizedBots = userBots.map(bot => ({ + id: bot.id, + userId: bot.userId, + name: bot.name, + handle: bot.handle, + bio: bot.bio, + avatarUrl: bot.avatarUrl, + personalityConfig: bot.personalityConfig, + llmProvider: bot.llmProvider, + llmModel: bot.llmModel, + scheduleConfig: bot.scheduleConfig, + autonomousMode: bot.autonomousMode, + isActive: bot.isActive, + isSuspended: bot.isSuspended, + suspensionReason: bot.suspensionReason, + lastPostAt: bot.lastPostAt, + createdAt: bot.createdAt, + updatedAt: bot.updatedAt, + })); + + return NextResponse.json({ + success: true, + bots: sanitizedBots, + }); + } catch (error) { + console.error('List bots error:', error); + + if (error instanceof Error && error.message === 'Authentication required') { + return NextResponse.json({ error: 'Authentication required' }, { status: 401 }); + } + + return NextResponse.json( + { error: 'Failed to list bots' }, + { status: 500 } + ); + } +} diff --git a/src/app/api/cron/bots/route.ts b/src/app/api/cron/bots/route.ts new file mode 100644 index 0000000..1768bce --- /dev/null +++ b/src/app/api/cron/bots/route.ts @@ -0,0 +1,47 @@ +/** + * Cron endpoint for bot scheduled posting + * + * Call this endpoint periodically (e.g., every minute) via cron job or PM2 + */ + +import { NextRequest, NextResponse } from 'next/server'; +import { processScheduledPosts } from '@/lib/bots/scheduler'; +import { processAllAutonomousBots } from '@/lib/bots/autonomous'; + +export async function POST(request: NextRequest) { + // Verify using AUTH_SECRET to prevent unauthorized access + const authHeader = request.headers.get('authorization'); + const authSecret = process.env.AUTH_SECRET; + + if (authSecret && authHeader !== `Bearer ${authSecret}`) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + try { + // Process scheduled posts + const scheduledResult = await processScheduledPosts(); + + // Process autonomous bots + const autonomousResult = await processAllAutonomousBots(); + + return NextResponse.json({ + success: true, + scheduled: { + processed: scheduledResult.processed, + skipped: scheduledResult.skipped, + errors: scheduledResult.errors.length, + }, + autonomous: { + total: autonomousResult.length, + posted: autonomousResult.filter(r => r.result.posted).length, + }, + timestamp: new Date().toISOString(), + }); + } catch (error) { + console.error('Cron bot processing error:', error); + return NextResponse.json( + { error: 'Failed to process bots', details: String(error) }, + { status: 500 } + ); + } +} diff --git a/src/app/api/media/preview/route.ts b/src/app/api/media/preview/route.ts index 3429e42..ad57f7f 100644 --- a/src/app/api/media/preview/route.ts +++ b/src/app/api/media/preview/route.ts @@ -1,5 +1,71 @@ import { NextRequest, NextResponse } from 'next/server'; +/** + * Check if a URL is from Reddit. + */ +function isRedditUrl(url: string): boolean { + try { + const parsed = new URL(url); + return parsed.hostname.endsWith('reddit.com') || parsed.hostname === 'redd.it'; + } catch { + return false; + } +} + +/** + * Fetch preview for Reddit URLs using their oEmbed API. + */ +async function fetchRedditPreview(url: string): Promise<{ + url: string; + title: string | null; + description: string | null; + image: string | null; +} | null> { + try { + const oembedUrl = `https://www.reddit.com/oembed?url=${encodeURIComponent(url)}`; + + const response = await fetch(oembedUrl, { + headers: { 'Accept': 'application/json' }, + signal: AbortSignal.timeout(5000), + }); + + if (!response.ok) { + return null; + } + + const data = await response.json(); + + // Extract title - try title field first, then parse from HTML + let title = data.title || null; + if (!title && data.html) { + const titleMatch = data.html.match(/href="[^"]+">([^<]+)<\/a>/); + if (titleMatch && titleMatch[1] && titleMatch[1] !== 'Comment') { + title = titleMatch[1]; + } + } + + // Build description from subreddit info + let description = null; + if (data.author_name) { + description = `Posted by ${data.author_name}`; + } else if (data.html) { + const subredditMatch = data.html.match(/r\/([a-zA-Z0-9_]+)/); + if (subredditMatch) { + description = `r/${subredditMatch[1]}`; + } + } + + return { + url, + title, + description, + image: data.thumbnail_url || null, + }; + } catch { + return null; + } +} + export async function GET(req: NextRequest) { try { const { searchParams } = new URL(req.url); @@ -9,18 +75,36 @@ export async function GET(req: NextRequest) { return NextResponse.json({ error: 'No URL provided' }, { status: 400 }); } - // Normalize URL - handle synapse.social etc + // Normalize URL if (!url.startsWith('http://') && !url.startsWith('https://')) { url = 'https://' + url; } + // Use Reddit-specific handler + if (isRedditUrl(url)) { + const preview = await fetchRedditPreview(url); + if (preview) { + return NextResponse.json(preview); + } + // Fall back to URL-only response if oEmbed fails + return NextResponse.json({ + url, + title: 'Reddit', + description: null, + image: null, + }); + } + + // Generic OG tag scraping for other sites let response; try { response = await fetch(url, { headers: { - 'User-Agent': 'SynapsisBot/1.0', + 'User-Agent': 'Mozilla/5.0 (compatible; SynapsisBot/1.0; +https://synapsis.social)', + 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', + 'Accept-Language': 'en-US,en;q=0.5', }, - signal: AbortSignal.timeout(5000), // 5s timeout + signal: AbortSignal.timeout(5000), }); } catch (fetchError) { console.warn(`Fetch failed for URL: ${url}`, fetchError); @@ -33,13 +117,11 @@ export async function GET(req: NextRequest) { const html = await response.text(); - // Simple regex extraction for OG tags const getMeta = (property: string) => { const regex = new RegExp(`]+(?:property|name)=["'](?:og:)?${property}["'][^>]+content=["']([^"']+)["']`, 'i'); const match = html.match(regex); if (match) return match[1]; - // Try different order const regexRev = new RegExp(`]+content=["']([^"']+)["'][^>]+(?:property|name)=["'](?:og:)?${property}["']`, 'i'); const matchRev = html.match(regexRev); return matchRev ? matchRev[1] : null; diff --git a/src/app/api/notifications/route.ts b/src/app/api/notifications/route.ts index ab36990..b7d460c 100644 --- a/src/app/api/notifications/route.ts +++ b/src/app/api/notifications/route.ts @@ -36,22 +36,29 @@ export async function GET(request: Request) { limit, }); - const payload = rows.map((row) => ({ - id: row.id, - type: row.type, - createdAt: row.createdAt, - readAt: row.readAt, - actor: row.actor ? { - id: row.actor.id, - handle: row.actor.handle, - displayName: row.actor.displayName, - avatarUrl: row.actor.avatarUrl, - } : null, - post: row.post ? { - id: row.post.id, - content: row.post.content, - } : null, - })); + type ActorInfo = { id: string; handle: string; displayName: string | null; avatarUrl: string | null }; + type PostInfo = { id: string; content: string }; + + const payload = rows.map((row) => { + const actor = row.actor as ActorInfo | null; + const post = row.post as PostInfo | null; + return { + id: row.id, + type: row.type, + createdAt: row.createdAt, + readAt: row.readAt, + actor: actor ? { + id: actor.id, + handle: actor.handle, + displayName: actor.displayName, + avatarUrl: actor.avatarUrl, + } : null, + post: post ? { + id: post.id, + content: post.content, + } : null, + }; + }); return NextResponse.json({ notifications: payload }); } catch (error) { diff --git a/src/app/api/posts/[id]/like/route.ts b/src/app/api/posts/[id]/like/route.ts index 1b9e8d7..88997bf 100644 --- a/src/app/api/posts/[id]/like/route.ts +++ b/src/app/api/posts/[id]/like/route.ts @@ -77,8 +77,9 @@ export async function POST(request: Request, context: RouteContext) { if (!postWithAuthor?.author) return; + const author = postWithAuthor.author as { handle: string }; // Construct the author's actor URL - const authorActorUrl = `https://${nodeDomain}/users/${postWithAuthor.author.handle}`; + const authorActorUrl = `https://${nodeDomain}/users/${author.handle}`; // For remote posts, we'd need to fetch the remote actor's inbox // For local posts, just log it since local delivery doesn't need federation diff --git a/src/app/api/posts/[id]/route.ts b/src/app/api/posts/[id]/route.ts index dd844b2..c4aeae4 100644 --- a/src/app/api/posts/[id]/route.ts +++ b/src/app/api/posts/[id]/route.ts @@ -144,18 +144,26 @@ export async function DELETE( ) { try { const { requireAuth } = await import('@/lib/auth'); + const { bots } = await import('@/db'); const user = await requireAuth(); const { id } = await params; const post = await db.query.posts.findFirst({ where: eq(posts.id, id), + with: { + bot: true, + }, }); if (!post) { return NextResponse.json({ error: 'Post not found' }, { status: 404 }); } - if (post.userId !== user.id) { + // Allow deletion if user owns the post OR if user owns the bot that made the post + const isPostOwner = post.userId === user.id; + const isBotOwner = post.bot && post.bot.ownerId === user.id; + + if (!isPostOwner && !isBotOwner) { return NextResponse.json({ error: 'Unauthorized' }, { status: 403 }); } @@ -174,11 +182,14 @@ export async function DELETE( // 2. Delete the post (cascades to media, likes, notifications) await db.delete(posts).where(eq(posts.id, id)); - // 3. Decrement user's postsCount - if (user.postsCount > 0) { + // 3. Decrement the post author's postsCount + const postAuthor = await db.query.users.findFirst({ + where: eq(users.id, post.userId), + }); + if (postAuthor && postAuthor.postsCount > 0) { await db.update(users) - .set({ postsCount: user.postsCount - 1 }) - .where(eq(users.id, user.id)); + .set({ postsCount: postAuthor.postsCount - 1 }) + .where(eq(users.id, post.userId)); } return NextResponse.json({ success: true }); diff --git a/src/app/api/posts/route.ts b/src/app/api/posts/route.ts index fc7eae6..6aa4bc0 100644 --- a/src/app/api/posts/route.ts +++ b/src/app/api/posts/route.ts @@ -5,7 +5,7 @@ import { eq, desc, and, inArray, isNull, notInArray, or } from 'drizzle-orm'; import type { SQL } from 'drizzle-orm'; import { z } from 'zod'; -const POST_MAX_LENGTH = 400; +const POST_MAX_LENGTH = 600; const CURATION_WINDOW_HOURS = 72; const CURATION_SEED_MULTIPLIER = 5; const CURATION_SEED_CAP = 200; @@ -229,6 +229,7 @@ export async function GET(request: Request) { where: baseFilter, with: { author: true, + bot: true, media: true, replyTo: { with: { author: true }, @@ -256,6 +257,7 @@ export async function GET(request: Request) { where: buildWhere(baseFilter, eq(posts.userId, userId)), with: { author: true, + bot: true, media: true, replyTo: { with: { author: true }, @@ -279,6 +281,7 @@ export async function GET(request: Request) { where: baseFilter, with: { author: true, + bot: true, media: true, replyTo: { with: { author: true }, @@ -396,11 +399,21 @@ export async function GET(request: Request) { try { const user = await requireAuth(); + // Get IDs of users the current user follows + const followRows = await db.select({ followingId: follows.followingId }) + .from(follows) + .where(eq(follows.followerId, user.id)); + const followingIds = followRows.map(row => row.followingId); + + // Include own posts + posts from followed users + const allowedUserIds = [user.id, ...followingIds]; + // Get local posts from people the user follows + their own posts const localPosts = await db.query.posts.findMany({ - where: baseFilter, + where: buildWhere(baseFilter, inArray(posts.userId, allowedUserIds)), with: { author: true, + bot: true, media: true, replyTo: { with: { author: true }, @@ -441,6 +454,7 @@ export async function GET(request: Request) { where: baseFilter, with: { author: true, + bot: true, media: true, replyTo: { with: { author: true }, diff --git a/src/app/api/search/route.ts b/src/app/api/search/route.ts index 0ba66e5..4cfad4c 100644 --- a/src/app/api/search/route.ts +++ b/src/app/api/search/route.ts @@ -11,6 +11,7 @@ type SearchUser = { bio: string | null; profileUrl?: string | null; isRemote?: boolean; + isBot?: boolean; }; const decodeEntities = (value: string) => @@ -111,6 +112,7 @@ export async function GET(request: Request) { displayName: users.displayName, avatarUrl: users.avatarUrl, bio: users.bio, + isBot: users.isBot, }) .from(users) .where(userConditions) diff --git a/src/app/api/users/[handle]/followers/route.ts b/src/app/api/users/[handle]/followers/route.ts index e060521..7259159 100644 --- a/src/app/api/users/[handle]/followers/route.ts +++ b/src/app/api/users/[handle]/followers/route.ts @@ -29,13 +29,15 @@ export async function GET(request: Request, context: RouteContext) { } // Get followers - const userFollowers = await db.query.follows.findMany({ - where: eq(follows.followingId, user.id), - with: { - follower: true, - }, - limit, - }); + const userFollowers = await db + .select({ + id: follows.id, + follower: users, + }) + .from(follows) + .innerJoin(users, eq(follows.followerId, users.id)) + .where(eq(follows.followingId, user.id)) + .limit(limit); return NextResponse.json({ followers: userFollowers.map(f => ({ @@ -44,6 +46,7 @@ export async function GET(request: Request, context: RouteContext) { displayName: f.follower.displayName, avatarUrl: f.follower.avatarUrl, bio: f.follower.bio, + isBot: f.follower.isBot, })), nextCursor: userFollowers.length === limit ? userFollowers[userFollowers.length - 1]?.id : null, }); diff --git a/src/app/api/users/[handle]/following/route.ts b/src/app/api/users/[handle]/following/route.ts index f454ef2..13d45a0 100644 --- a/src/app/api/users/[handle]/following/route.ts +++ b/src/app/api/users/[handle]/following/route.ts @@ -29,13 +29,15 @@ export async function GET(request: Request, context: RouteContext) { } // Get local following - const userFollowing = await db.query.follows.findMany({ - where: eq(follows.followerId, user.id), - with: { - following: true, - }, - limit, - }); + const userFollowing = await db + .select({ + id: follows.id, + following: users, + }) + .from(follows) + .innerJoin(users, eq(follows.followingId, users.id)) + .where(eq(follows.followerId, user.id)) + .limit(limit); const localFollowing = userFollowing.map(f => ({ id: f.following.id, @@ -44,6 +46,7 @@ export async function GET(request: Request, context: RouteContext) { avatarUrl: f.following.avatarUrl, bio: f.following.bio, isRemote: false, + isBot: f.following.isBot, })); // Get remote following diff --git a/src/app/api/users/[handle]/route.ts b/src/app/api/users/[handle]/route.ts index 63f675c..f017119 100644 --- a/src/app/api/users/[handle]/route.ts +++ b/src/app/api/users/[handle]/route.ts @@ -119,20 +119,39 @@ export async function GET(request: Request, context: RouteContext) { } // Return user profile (without sensitive data) - return NextResponse.json({ - user: { - id: user.id, - handle: user.handle, - displayName: user.displayName, - bio: user.bio, - avatarUrl: user.avatarUrl, - headerUrl: user.headerUrl, - followersCount: user.followersCount, - followingCount: user.followingCount, - postsCount: user.postsCount, - createdAt: user.createdAt, + // Include bot info if this is a bot account + const userResponse: Record = { + id: user.id, + handle: user.handle, + displayName: user.displayName, + bio: user.bio, + avatarUrl: user.avatarUrl, + headerUrl: user.headerUrl, + followersCount: user.followersCount, + followingCount: user.followingCount, + postsCount: user.postsCount, + createdAt: user.createdAt, + website: user.website, + movedTo: user.movedTo, + isBot: user.isBot, + }; + + // If this is a bot, include owner info + if (user.isBot && user.botOwnerId) { + const owner = await db.query.users.findFirst({ + where: eq(users.id, user.botOwnerId), + }); + if (owner) { + userResponse.botOwner = { + id: owner.id, + handle: owner.handle, + displayName: owner.displayName, + avatarUrl: owner.avatarUrl, + }; } - }); + } + + return NextResponse.json({ user: userResponse }); } catch (error) { console.error('Get user error:', error); return NextResponse.json({ error: 'Failed to get user' }, { status: 500 }); diff --git a/src/app/api/users/route.ts b/src/app/api/users/route.ts index 10e9cb0..4a92b52 100644 --- a/src/app/api/users/route.ts +++ b/src/app/api/users/route.ts @@ -20,6 +20,7 @@ export async function GET(request: NextRequest) { bio: users.bio, avatarUrl: users.avatarUrl, createdAt: users.createdAt, + isBot: users.isBot, }) .from(users) .where(sql`${users.isSuspended} IS FALSE`) diff --git a/src/app/explore/page.tsx b/src/app/explore/page.tsx index 0e2dba2..32d18b1 100644 --- a/src/app/explore/page.tsx +++ b/src/app/explore/page.tsx @@ -6,6 +6,7 @@ import { SearchIcon, TrendingIcon, UsersIcon } from '@/components/Icons'; import { PostCard } from '@/components/PostCard'; import { Post } from '@/lib/types'; import { formatFullHandle } from '@/lib/utils/handle'; +import { Bot } from 'lucide-react'; interface User { id: string; @@ -15,6 +16,7 @@ interface User { bio?: string; profileUrl?: string | null; isRemote?: boolean; + isBot?: boolean; } function UserCard({ user }: { user: User }) { @@ -28,7 +30,27 @@ function UserCard({ user }: { user: User }) { )}
-
{user.displayName || user.handle}
+
+ {user.displayName || user.handle} + {user.isBot && ( + + + AI Account + + )} +
{formatFullHandle(user.handle)}
{user.bio &&
{user.bio}
}
@@ -172,11 +194,19 @@ export default function ExplorePage() {

No trending posts yet

) : ( -
- {trendingPosts.map((post) => ( - - ))} -
+ <> +
+
Fediverse feed
+
+ This feed shows posts from across the fediverse, including content from accounts that users on this node follow. Discover new voices and conversations from the wider federated network. +
+
+
+ {trendingPosts.map((post) => ( + + ))} +
+ ) )} diff --git a/src/app/globals.css b/src/app/globals.css index 9181840..bde235a 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -357,6 +357,7 @@ a.btn-primary:visited { margin-bottom: 12px; border: 1px solid var(--border); background: #000; + z-index: 2; } .video-embed-container iframe { diff --git a/src/app/search/page.tsx b/src/app/search/page.tsx index f02ed02..5f310d1 100644 --- a/src/app/search/page.tsx +++ b/src/app/search/page.tsx @@ -6,6 +6,7 @@ import { useSearchParams, useRouter } from 'next/navigation'; import { formatFullHandle } from '@/lib/utils/handle'; import { PostCard } from '@/components/PostCard'; import { Post } from '@/lib/types'; +import { Bot } from 'lucide-react'; interface User { id: string; @@ -15,6 +16,7 @@ interface User { bio?: string; profileUrl?: string | null; isRemote?: boolean; + isBot?: boolean; } @@ -84,7 +86,27 @@ function UserCard({ user }: { user: User }) { )}
-
{user.displayName || user.handle}
+
+ {user.displayName || user.handle} + {user.isBot && ( + + + AI Account + + )} +
{formatFullHandle(user.handle)}
{user.bio && (
('identity'); + const [uploadingAvatar, setUploadingAvatar] = useState(false); + const [uploadingBanner, setUploadingBanner] = useState(false); + + const [formData, setFormData] = useState({ + name: '', + handle: '', + bio: '', + avatarUrl: '', + headerUrl: '', + systemPrompt: '', + llmProvider: 'openai', + llmModel: 'gpt-4', + llmApiKey: '', + autonomousMode: false, + postingFrequency: 'hourly', + customIntervalMinutes: 60, + }); + + const [sources, setSources] = useState([]); + const [newSource, setNewSource] = useState({ + type: 'rss', + url: '', + fetchIntervalMinutes: 30, + braveQuery: '', + braveFreshness: 'pw', + newsProvider: 'newsapi', + newsQuery: '', + }); + const [sourcesToDelete, setSourcesToDelete] = useState([]); + + useEffect(() => { + fetchBot(); + }, [botId]); + + const fetchBot = async () => { + try { + const [botRes, sourcesRes] = await Promise.all([ + fetch(`/api/bots/${botId}`), + fetch(`/api/bots/${botId}/sources`), + ]); + + if (!botRes.ok) { + setError('Bot not found'); + setLoading(false); + return; + } + + const botData = await botRes.json(); + const bot = botData.bot; + + const personalityConfig = typeof bot.personalityConfig === 'string' + ? JSON.parse(bot.personalityConfig) + : bot.personalityConfig || {}; + + const scheduleConfig = typeof bot.scheduleConfig === 'string' + ? JSON.parse(bot.scheduleConfig) + : bot.scheduleConfig || {}; + + // Determine posting frequency from interval + let postingFrequency = 'hourly'; + let customIntervalMinutes = 60; + if (scheduleConfig?.intervalMinutes) { + const interval = scheduleConfig.intervalMinutes; + if (interval === 60) postingFrequency = 'hourly'; + else if (interval === 120) postingFrequency = 'every_2_hours'; + else if (interval === 240) postingFrequency = 'every_4_hours'; + else if (interval === 360) postingFrequency = 'every_6_hours'; + else if (interval === 1440) postingFrequency = 'daily'; + else { + postingFrequency = 'custom'; + customIntervalMinutes = interval; + } + } + + setFormData({ + name: bot.name || '', + handle: bot.handle || '', + bio: bot.bio || '', + avatarUrl: bot.avatarUrl || '', + headerUrl: bot.headerUrl || '', + systemPrompt: personalityConfig.systemPrompt || '', + llmProvider: bot.llmProvider || 'openai', + llmModel: bot.llmModel || 'gpt-4', + llmApiKey: '', // Don't pre-fill API key for security + autonomousMode: bot.autonomousMode || false, + postingFrequency, + customIntervalMinutes, + }); + + if (sourcesRes.ok) { + const sourcesData = await sourcesRes.json(); + setSources(sourcesData.sources || []); + } + } catch (err) { + console.error('Failed to fetch bot:', err); + setError('Failed to load bot data'); + } finally { + setLoading(false); + } + }; + + const handleAddSource = () => { + // Validate based on type + if (newSource.type === 'brave_news') { + if (!newSource.braveQuery || !newSource.apiKey) return; + // Build URL from config + const url = new URL('https://api.search.brave.com/res/v1/news/search'); + url.searchParams.set('q', newSource.braveQuery); + if (newSource.braveFreshness) url.searchParams.set('freshness', newSource.braveFreshness); + if (newSource.braveCountry) url.searchParams.set('country', newSource.braveCountry); + setSources([...sources, { ...newSource, url: url.toString() }]); + } else if (newSource.type === 'news_api') { + if (!newSource.newsQuery || !newSource.apiKey) return; + // Build URL from config + let baseUrl: string; + const params = new URLSearchParams(); + switch (newSource.newsProvider) { + case 'gnews': + baseUrl = 'https://gnews.io/api/v4/search'; + params.set('q', newSource.newsQuery); + if (newSource.newsCountry) params.set('country', newSource.newsCountry); + if (newSource.newsLanguage) params.set('lang', newSource.newsLanguage); + if (newSource.newsCategory) params.set('topic', newSource.newsCategory); + break; + case 'newsdata': + baseUrl = 'https://newsdata.io/api/1/news'; + params.set('q', newSource.newsQuery); + if (newSource.newsCountry) params.set('country', newSource.newsCountry); + if (newSource.newsLanguage) params.set('language', newSource.newsLanguage); + if (newSource.newsCategory) params.set('category', newSource.newsCategory); + break; + default: + baseUrl = 'https://newsapi.org/v2/everything'; + params.set('q', newSource.newsQuery); + if (newSource.newsLanguage) params.set('language', newSource.newsLanguage); + } + setSources([...sources, { ...newSource, url: `${baseUrl}?${params.toString()}` }]); + } else { + if (!newSource.url) return; + setSources([...sources, { ...newSource }]); + } + setNewSource({ + type: 'rss', + url: '', + fetchIntervalMinutes: 30, + braveQuery: '', + braveFreshness: 'pw', + newsProvider: 'newsapi', + newsQuery: '', + }); + }; + + const handleRemoveSource = (index: number) => { + const source = sources[index]; + if (source.id) { + setSourcesToDelete([...sourcesToDelete, source.id]); + } + setSources(sources.filter((_, i) => i !== index)); + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setSaving(true); + setError(''); + + try { + // Calculate interval minutes + let intervalMinutes = 60; + if (formData.postingFrequency === 'hourly') intervalMinutes = 60; + else if (formData.postingFrequency === 'every_2_hours') intervalMinutes = 120; + else if (formData.postingFrequency === 'every_4_hours') intervalMinutes = 240; + else if (formData.postingFrequency === 'every_6_hours') intervalMinutes = 360; + else if (formData.postingFrequency === 'daily') intervalMinutes = 1440; + else if (formData.postingFrequency === 'custom') intervalMinutes = formData.customIntervalMinutes; + + // Update bot + const updatePayload: Record = { + name: formData.name, + bio: formData.bio, + avatarUrl: formData.avatarUrl || null, + headerUrl: formData.headerUrl || null, + personality: { + systemPrompt: formData.systemPrompt, + temperature: 0.7, + maxTokens: 500, + }, + llmProvider: formData.llmProvider, + llmModel: formData.llmModel, + autonomousMode: formData.autonomousMode, + schedule: formData.autonomousMode ? { + type: 'interval', + intervalMinutes, + } : undefined, + }; + + // Only include API key if user entered a new one + if (formData.llmApiKey) { + updatePayload.llmApiKey = formData.llmApiKey; + } + + const response = await fetch(`/api/bots/${botId}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(updatePayload), + }); + + if (!response.ok) { + const data = await response.json(); + throw new Error(data.error || 'Failed to update bot'); + } + + // Delete removed sources + for (const sourceId of sourcesToDelete) { + await fetch(`/api/bots/${botId}/sources/${sourceId}`, { method: 'DELETE' }); + } + + // Add new sources (ones without id) + for (const source of sources) { + if (!source.id) { + const sourcePayload: Record = { + type: source.type, + url: source.url, + subreddit: source.subreddit, + apiKey: source.apiKey, + }; + + // Add config for brave_news + if (source.type === 'brave_news' && source.braveQuery) { + sourcePayload.braveNewsConfig = { + query: source.braveQuery, + freshness: source.braveFreshness, + country: source.braveCountry, + }; + } + + // Add config for news_api + if (source.type === 'news_api' && source.newsQuery) { + sourcePayload.newsApiConfig = { + provider: source.newsProvider, + query: source.newsQuery, + category: source.newsCategory, + country: source.newsCountry, + language: source.newsLanguage, + }; + } + + await fetch(`/api/bots/${botId}/sources`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(sourcePayload), + }); + } + } + + router.push(`/settings/bots/${botId}`); + } catch (err) { + console.error('Update bot error:', err); + setError(err instanceof Error ? err.message : 'Failed to update bot'); + } finally { + setSaving(false); + } + }; + + + const renderStep = () => { + switch (step) { + case 'identity': + return ( +
+
+ + setFormData({ ...formData, name: e.target.value })} + className="input" + placeholder="My Awesome Bot" + required + /> +
+ +
+ +
+ @ + +
+

+ Handle cannot be changed after creation +

+
+ +
+ +