Initial commit

This commit is contained in:
root
2026-01-26 08:34:48 +01:00
commit 387314581f
216 changed files with 81204 additions and 0 deletions
+152
View File
@@ -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 }
);
}
}
@@ -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 }
);
}
}
@@ -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 }
);
}
}
+75
View File
@@ -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 }
);
}
}
@@ -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 }
);
}
}
+85
View File
@@ -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 }
);
}
}
+369
View File
@@ -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');
});
});
+170
View File
@@ -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;
}
}
+62
View File
@@ -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 }
);
}
}
+280
View File
@@ -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<typeof updateBot>[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 }
);
}
}
@@ -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 }
);
}
}
@@ -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<typeof updateSource>[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 }
);
}
}
+239
View File
@@ -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 }
);
}
}
+74
View File
@@ -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 }
);
}
}
+189
View File
@@ -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 }
);
}
}