Initial commit
This commit is contained in:
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user