From 5f21ea8e5d4a6f7f7b615a207c9c6134cdf5d362 Mon Sep 17 00:00:00 2001 From: cyph3rasi Date: Sun, 8 Mar 2026 18:29:42 -0700 Subject: [PATCH] Fix reply consistency and source validation --- src/app/api/bots/[id]/sources/route.ts | 11 ++ src/app/api/bots/validate-source/route.ts | 94 ++++++++++++ src/app/api/posts/[id]/route.ts | 61 +------- src/app/api/posts/route.ts | 168 +++++++++++----------- src/app/api/posts/swarm/route.ts | 22 --- src/app/api/swarm/posts/[id]/route.ts | 2 +- src/app/api/swarm/replies/route.ts | 36 +++-- src/app/api/swarm/timeline/route.ts | 8 ++ src/app/api/users/[handle]/posts/route.ts | 24 +--- src/app/bots/[id]/edit/page.tsx | 120 ++++++++++++---- src/app/bots/[id]/page.tsx | 3 +- src/app/bots/new/page.tsx | 120 ++++++++++++---- src/app/chat/page.tsx | 21 ++- src/app/moderation/page.tsx | 3 +- src/app/notifications/page.tsx | 6 +- src/app/search/page.tsx | 4 +- src/app/settings/moderation/page.tsx | 3 +- src/lib/bots/contentSourceValidation.ts | 83 +++++++++++ src/lib/swarm/timeline.ts | 23 ++- src/lib/utils/handle.ts | 9 ++ 20 files changed, 545 insertions(+), 276 deletions(-) create mode 100644 src/app/api/bots/validate-source/route.ts create mode 100644 src/lib/bots/contentSourceValidation.ts diff --git a/src/app/api/bots/[id]/sources/route.ts b/src/app/api/bots/[id]/sources/route.ts index 1975544..1788b2b 100644 --- a/src/app/api/bots/[id]/sources/route.ts +++ b/src/app/api/bots/[id]/sources/route.ts @@ -20,6 +20,7 @@ import { MAX_KEYWORDS, MAX_KEYWORD_LENGTH, } from '@/lib/bots/contentSource'; +import { validateSourceReachability } from '@/lib/bots/contentSourceValidation'; type RouteContext = { params: Promise<{ id: string }> }; @@ -115,6 +116,16 @@ export async function POST(request: Request, context: RouteContext) { return NextResponse.json({ error: 'Not authorized' }, { status: 403 }); } + await validateSourceReachability({ + type: data.type, + url: data.url, + subreddit: data.subreddit, + apiKey: data.apiKey, + keywords: data.keywords, + braveNewsConfig: data.braveNewsConfig, + newsApiConfig: data.newsApiConfig, + }); + // Add the content source const source = await addSource(botId, { type: data.type, diff --git a/src/app/api/bots/validate-source/route.ts b/src/app/api/bots/validate-source/route.ts new file mode 100644 index 0000000..2294681 --- /dev/null +++ b/src/app/api/bots/validate-source/route.ts @@ -0,0 +1,94 @@ +import { NextResponse } from 'next/server'; +import { z } from 'zod'; +import { requireAuth } from '@/lib/auth'; +import { MAX_KEYWORDS, MAX_KEYWORD_LENGTH, SUPPORTED_SOURCE_TYPES, ContentSourceValidationError } from '@/lib/bots/contentSource'; +import { validateSourceReachability } from '@/lib/bots/contentSourceValidation'; + +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(); + +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(); + +const validateSourceSchema = 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) => data.type !== 'reddit' || !!data.subreddit, + { message: 'Subreddit name is required for Reddit sources', path: ['subreddit'] } +).refine( + (data) => !['news_api', 'brave_news'].includes(data.type) || !!data.apiKey, + { message: 'API key is required for news API sources', path: ['apiKey'] } +).refine( + (data) => data.type !== 'brave_news' || !!data.braveNewsConfig?.query, + { message: 'Search query is required for Brave News sources', path: ['braveNewsConfig'] } +); + +export async function POST(request: Request) { + try { + await requireAuth(); + const body = await request.json(); + const data = validateSourceSchema.parse(body); + + await validateSourceReachability({ + 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 }); + } catch (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 ContentSourceValidationError) { + return NextResponse.json( + { error: error.message, code: error.code, details: error.errors }, + { status: 400 } + ); + } + + console.error('Validate content source error:', error); + return NextResponse.json( + { error: 'Failed to validate content source' }, + { status: 500 } + ); + } +} diff --git a/src/app/api/posts/[id]/route.ts b/src/app/api/posts/[id]/route.ts index 5557487..a9dfc00 100644 --- a/src/app/api/posts/[id]/route.ts +++ b/src/app/api/posts/[id]/route.ts @@ -103,59 +103,7 @@ export async function GET( })) || [], })); - const localSwarmReplyId = `swarm:${originDomain}:${originalPostId}`; - const localReplies = await db.query.posts.findMany({ - where: and( - eq(posts.swarmReplyToId, localSwarmReplyId), - eq(posts.isRemoved, false) - ), - with: { - author: true, - media: true, - }, - orderBy: [desc(posts.createdAt)], - }); - - if (localReplies.length > 0) { - let likedReplyIds = new Set(); - let repostedReplyIds = new Set(); - - try { - const { requireAuth } = await import('@/lib/auth'); - const { likes } = await import('@/db'); - const viewer = await requireAuth(); - const localReplyIds = localReplies.map(reply => reply.id); - - const viewerLikes = await db.query.likes.findMany({ - where: and( - eq(likes.userId, viewer.id), - inArray(likes.postId, localReplyIds) - ), - }); - likedReplyIds = new Set(viewerLikes.map(like => like.postId)); - - const viewerReposts = await db.query.posts.findMany({ - where: and( - eq(posts.userId, viewer.id), - inArray(posts.repostOfId, localReplyIds), - eq(posts.isRemoved, false) - ), - }); - repostedReplyIds = new Set(viewerReposts.map(reply => reply.repostOfId)); - } catch { - } - - const formattedLocalReplies = localReplies.map((reply: any) => ({ - ...reply, - isLiked: likedReplyIds.has(reply.id), - isReposted: repostedReplyIds.has(reply.id), - })); - - replyPosts = [...formattedLocalReplies, ...replyPosts] - .sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()); - - mainPost.repliesCount = (mainPost.repliesCount || 0) + localReplies.length; - } + mainPost.repliesCount = replyPosts.length; // Check if current user has liked this post try { @@ -214,6 +162,11 @@ export async function GET( orderBy: [desc(posts.createdAt)], }); + mainPost = { + ...post, + repliesCount: replies.length, + }; + let allPostIds = [post.id, ...replies.map(r => r.id)]; try { @@ -243,7 +196,7 @@ export async function GET( const repostedPostIds = new Set(viewerReposts.map(r => r.repostOfId)); mainPost = { - ...post, + ...mainPost, isLiked: likedPostIds.has(post.id), isReposted: repostedPostIds.has(post.id), } as any; diff --git a/src/app/api/posts/route.ts b/src/app/api/posts/route.ts index 4c46f80..85459a7 100644 --- a/src/app/api/posts/route.ts +++ b/src/app/api/posts/route.ts @@ -86,16 +86,92 @@ export async function POST(request: Request) { linkPreviewImage: data.linkPreview?.image, }).returning(); - let attachedMedia: typeof media.$inferSelect[] = []; + let unattachedMedia: typeof media.$inferSelect[] = []; if (data.mediaIds?.length) { - await db.update(media) - .set({ postId: post.id }) - .where(and( + unattachedMedia = await db.query.media.findMany({ + where: and( inArray(media.id, data.mediaIds), eq(media.userId, user.id), isNull(media.postId), - )); + ), + }); + } + try { + if (data.swarmReplyTo) { + const protocol = data.swarmReplyTo.nodeDomain.includes('localhost') ? 'http' : 'https'; + const targetUrl = `${protocol}://${data.swarmReplyTo.nodeDomain}/api/swarm/replies`; + + const replyPayload = { + postId: data.swarmReplyTo.postId, + reply: { + id: post.id, + content: post.content, + createdAt: post.createdAt.toISOString(), + author: { + handle: user.handle, + displayName: user.displayName || user.handle, + avatarUrl: user.avatarUrl || undefined, + did: user.did, + publicKey: user.publicKey, + }, + nodeDomain, + mediaUrls: unattachedMedia.map(m => m.url), + }, + }; + + const { createSignedPayload } = await import('@/lib/swarm/signature'); + const { payload, signature } = await createSignedPayload(replyPayload); + + const response = await fetch(targetUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Swarm-Source-Domain': nodeDomain, + 'X-Swarm-Signature': signature, + }, + body: JSON.stringify(payload), + signal: AbortSignal.timeout(10000), + }); + + if (!response.ok) { + const body = await response.text().catch(() => ''); + throw new Error(body || `Remote node rejected reply (${response.status})`); + } + } + + if (data.mediaIds?.length) { + await db.update(media) + .set({ postId: post.id }) + .where(and( + inArray(media.id, data.mediaIds), + eq(media.userId, user.id), + isNull(media.postId), + )); + } + + // Update user's post count (atomic increment to prevent race conditions) + await db.update(users) + .set({ postsCount: sql`${users.postsCount} + 1` }) + .where(eq(users.id, user.id)); + + // If this is a reply, update the parent's reply count (atomic increment) + if (data.replyToId) { + await db.update(posts) + .set({ repliesCount: sql`${posts.repliesCount} + 1` }) + .where(eq(posts.id, data.replyToId)); + } + } catch (err) { + await db.delete(posts).where(eq(posts.id, post.id)); + console.error('[Swarm] Error creating synchronized reply:', err); + return NextResponse.json( + { error: err instanceof Error ? err.message : 'Failed to deliver reply to origin node' }, + { status: 502 } + ); + } + + let attachedMedia: typeof media.$inferSelect[] = []; + if (data.mediaIds?.length) { attachedMedia = await db.query.media.findMany({ where: and( inArray(media.id, data.mediaIds), @@ -105,66 +181,6 @@ export async function POST(request: Request) { }); } - // Update user's post count (atomic increment to prevent race conditions) - await db.update(users) - .set({ postsCount: sql`${users.postsCount} + 1` }) - .where(eq(users.id, user.id)); - - // If this is a reply, update the parent's reply count (atomic increment) - if (data.replyToId) { - await db.update(posts) - .set({ repliesCount: sql`${posts.repliesCount} + 1` }) - .where(eq(posts.id, data.replyToId)); - } - - if (data.swarmReplyTo) { - (async () => { - try { - const protocol = data.swarmReplyTo!.nodeDomain.includes('localhost') ? 'http' : 'https'; - const targetUrl = `${protocol}://${data.swarmReplyTo!.nodeDomain}/api/swarm/replies`; - - const replyPayload = { - postId: data.swarmReplyTo!.postId, - reply: { - id: post.id, - content: post.content, - createdAt: post.createdAt.toISOString(), - author: { - handle: user.handle, - displayName: user.displayName || user.handle, - avatarUrl: user.avatarUrl || undefined, - did: user.did, - publicKey: user.publicKey, - }, - nodeDomain, - mediaUrls: attachedMedia.map(m => m.url), - }, - }; - - const { createSignedPayload } = await import('@/lib/swarm/signature'); - const { payload, signature } = await createSignedPayload(replyPayload); - - const response = await fetch(targetUrl, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-Swarm-Source-Domain': nodeDomain, - 'X-Swarm-Signature': signature, - }, - body: JSON.stringify(payload), - }); - - if (response.ok) { - console.log(`[Swarm] Reply delivered to ${data.swarmReplyTo!.nodeDomain}`); - } else { - console.error(`[Swarm] Failed to deliver reply: ${response.status}`); - } - } catch (err) { - console.error('[Swarm] Error delivering reply:', err); - } - })(); - } - // Handle local mentions (create notifications for users on this node) (async () => { try { @@ -538,26 +554,6 @@ export async function GET(request: Request) { includeNsfw, }); - // Transform swarm posts to match local post format - const localSwarmReplyIds = swarmResult.posts.map(sp => `swarm:${sp.nodeDomain}:${sp.id}`); - const localReplyCounts = localSwarmReplyIds.length > 0 - ? await db.select({ - swarmReplyToId: posts.swarmReplyToId, - count: sql`count(*)`, - }) - .from(posts) - .where(and( - inArray(posts.swarmReplyToId, localSwarmReplyIds), - eq(posts.isRemoved, false) - )) - .groupBy(posts.swarmReplyToId) - : []; - const localReplyCountMap = new Map( - localReplyCounts - .filter(row => row.swarmReplyToId) - .map(row => [row.swarmReplyToId as string, Number(row.count || 0)]) - ); - const swarmPosts = swarmResult.posts.map(sp => ({ id: `swarm:${sp.nodeDomain}:${sp.id}`, originalPostId: sp.id, // Keep the original ID for replies @@ -565,7 +561,7 @@ export async function GET(request: Request) { createdAt: new Date(sp.createdAt), likesCount: sp.likeCount, repostsCount: sp.repostCount, - repliesCount: sp.replyCount + (localReplyCountMap.get(`swarm:${sp.nodeDomain}:${sp.id}`) || 0), + repliesCount: sp.replyCount, isSwarm: true, nodeDomain: sp.nodeDomain, author: { diff --git a/src/app/api/posts/swarm/route.ts b/src/app/api/posts/swarm/route.ts index e85a2c1..cf8b3aa 100644 --- a/src/app/api/posts/swarm/route.ts +++ b/src/app/api/posts/swarm/route.ts @@ -5,10 +5,8 @@ */ import { NextRequest, NextResponse } from 'next/server'; -import { db, posts } from '@/db'; import { fetchSwarmTimeline } from '@/lib/swarm/timeline'; import { getSession } from '@/lib/auth'; -import { and, eq, inArray, sql } from 'drizzle-orm'; import { getViewerSwarmLikedPostIds } from '@/lib/swarm/likes'; /** @@ -20,7 +18,6 @@ import { getViewerSwarmLikedPostIds } from '@/lib/swarm/likes'; export async function GET(request: NextRequest) { try { const { searchParams } = new URL(request.url); - const refresh = searchParams.get('refresh') === 'true'; const cursor = searchParams.get('cursor') || undefined; // Check user's NSFW preference @@ -34,24 +31,6 @@ export async function GET(request: NextRequest) { // Fetch swarm timeline (no caching - user preferences vary) const timeline = await fetchSwarmTimeline(10, 15, { includeNsfw, cursor }); - const swarmReplyIds = timeline.posts.map(post => `swarm:${post.nodeDomain}:${post.id}`); - const localReplyCounts = db && swarmReplyIds.length > 0 - ? await db.select({ - swarmReplyToId: posts.swarmReplyToId, - count: sql`count(*)::int`, - }) - .from(posts) - .where(and( - inArray(posts.swarmReplyToId, swarmReplyIds), - eq(posts.isRemoved, false) - )) - .groupBy(posts.swarmReplyToId) - : []; - const localReplyCountMap = new Map( - localReplyCounts - .filter(row => row.swarmReplyToId) - .map(row => [row.swarmReplyToId as string, row.count]) - ); const session = await getSession().catch(() => null); const viewer = session?.user; @@ -71,7 +50,6 @@ export async function GET(request: NextRequest) { return NextResponse.json({ posts: timeline.posts.map(post => ({ ...post, - replyCount: post.replyCount + (localReplyCountMap.get(`swarm:${post.nodeDomain}:${post.id}`) || 0), isLiked: likedPostIds.has(`swarm:${post.nodeDomain}:${post.id}`), })), sources: timeline.sources, diff --git a/src/app/api/swarm/posts/[id]/route.ts b/src/app/api/swarm/posts/[id]/route.ts index 3788c32..d816432 100644 --- a/src/app/api/swarm/posts/[id]/route.ts +++ b/src/app/api/swarm/posts/[id]/route.ts @@ -70,7 +70,7 @@ export async function GET(request: NextRequest, context: RouteContext) { createdAt: post.createdAt.toISOString(), likesCount: post.likesCount, repostsCount: post.repostsCount, - repliesCount: post.repliesCount, + repliesCount: replies.length, author: { handle: author.handle, displayName: author.displayName, diff --git a/src/app/api/swarm/replies/route.ts b/src/app/api/swarm/replies/route.ts index e1a9ced..bd260e1 100644 --- a/src/app/api/swarm/replies/route.ts +++ b/src/app/api/swarm/replies/route.ts @@ -7,7 +7,7 @@ import { NextRequest, NextResponse } from 'next/server'; import { db, posts, users, media, notifications } from '@/db'; -import { eq, desc, and } from 'drizzle-orm'; +import { eq, desc, and, sql } from 'drizzle-orm'; import { z } from 'zod'; import { verifySwarmRequest } from '@/lib/swarm/signature'; import { upsertRemoteUser } from '@/lib/swarm/user-cache'; @@ -31,6 +31,20 @@ const swarmReplySchema = z.object({ }), }); +async function syncParentReplyCount(postId: string) { + const [{ count }] = await db + .select({ count: sql`count(*)::int` }) + .from(posts) + .where(and( + eq(posts.replyToId, postId), + eq(posts.isRemoved, false) + )); + + await db.update(posts) + .set({ repliesCount: Number(count || 0) }) + .where(eq(posts.id, postId)); +} + /** * POST /api/swarm/replies * @@ -129,9 +143,7 @@ export async function POST(request: NextRequest) { ); } - await db.update(posts) - .set({ repliesCount: parentPost.repliesCount + 1 }) - .where(eq(posts.id, data.postId)); + await syncParentReplyCount(data.postId); if (parentPost.userId !== remoteUser.id) { await db.insert(notifications).values({ @@ -183,21 +195,15 @@ export async function DELETE(request: NextRequest) { return NextResponse.json({ success: true, message: 'Reply not found or already deleted' }); } - // Decrement parent's reply count - if (existingReply.replyToId) { - const parentPost = await db.query.posts.findFirst({ - where: eq(posts.id, existingReply.replyToId), - }); - if (parentPost && parentPost.repliesCount > 0) { - await db.update(posts) - .set({ repliesCount: parentPost.repliesCount - 1 }) - .where(eq(posts.id, existingReply.replyToId)); - } - } + const parentReplyToId = existingReply.replyToId; // Delete the reply await db.delete(posts).where(eq(posts.id, existingReply.id)); + if (parentReplyToId) { + await syncParentReplyCount(parentReplyToId); + } + console.log(`[Swarm] Deleted reply ${swarmReplyId} from ${nodeDomain}`); return NextResponse.json({ success: true }); diff --git a/src/app/api/swarm/timeline/route.ts b/src/app/api/swarm/timeline/route.ts index baf50eb..7d7d887 100644 --- a/src/app/api/swarm/timeline/route.ts +++ b/src/app/api/swarm/timeline/route.ts @@ -12,6 +12,9 @@ export interface SwarmPost { id: string; content: string; createdAt: string; + isReply?: boolean; + replyToId?: string | null; + swarmReplyToId?: string | null; author: { handle: string; displayName: string; @@ -84,6 +87,8 @@ export async function GET(request: NextRequest) { id: posts.id, content: posts.content, createdAt: posts.createdAt, + replyToId: posts.replyToId, + swarmReplyToId: posts.swarmReplyToId, isNsfw: posts.isNsfw, likesCount: posts.likesCount, repostsCount: posts.repostsCount, @@ -120,6 +125,9 @@ export async function GET(request: NextRequest) { id: post.id, content: post.content, createdAt: post.createdAt.toISOString(), + isReply: Boolean(post.replyToId || post.swarmReplyToId), + replyToId: post.replyToId, + swarmReplyToId: post.swarmReplyToId, author: { handle: post.authorHandle, displayName: post.authorDisplayName || post.authorHandle, diff --git a/src/app/api/users/[handle]/posts/route.ts b/src/app/api/users/[handle]/posts/route.ts index 21d71e5..0381a80 100644 --- a/src/app/api/users/[handle]/posts/route.ts +++ b/src/app/api/users/[handle]/posts/route.ts @@ -146,28 +146,6 @@ export async function GET(request: Request, context: RouteContext) { avatarUrl: profile.avatarUrl, }; - const swarmPostIds = profileData.posts.map((post: any) => `swarm:${remote.domain}:${post.id}`); - let localReplyCounts = new Map(); - - if (swarmPostIds.length > 0) { - const counts = await db.select({ - swarmReplyToId: posts.swarmReplyToId, - replyCount: sql`count(*)::int`, - }) - .from(posts) - .where(and( - inArray(posts.swarmReplyToId, swarmPostIds), - eq(posts.isRemoved, false) - )) - .groupBy(posts.swarmReplyToId); - - localReplyCounts = new Map( - counts - .filter(row => row.swarmReplyToId) - .map(row => [row.swarmReplyToId as string, row.replyCount]) - ); - } - const remotePosts = profileData.posts.map((post: any) => ({ id: post.id, originalPostId: post.id, @@ -175,7 +153,7 @@ export async function GET(request: Request, context: RouteContext) { createdAt: post.createdAt, likesCount: post.likesCount || 0, repostsCount: post.repostsCount || 0, - repliesCount: (post.repliesCount || 0) + (localReplyCounts.get(`swarm:${remote.domain}:${post.id}`) || 0), + repliesCount: post.repliesCount || 0, author, media: post.media || [], linkPreviewUrl: post.linkPreviewUrl || null, diff --git a/src/app/bots/[id]/edit/page.tsx b/src/app/bots/[id]/edit/page.tsx index 7cc7171..3ddc27b 100644 --- a/src/app/bots/[id]/edit/page.tsx +++ b/src/app/bots/[id]/edit/page.tsx @@ -46,6 +46,8 @@ export default function EditBotPage() { const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); const [error, setError] = useState(''); + const [sourceError, setSourceError] = useState(''); + const [validatingSource, setValidatingSource] = useState(false); const [step, setStep] = useState<'identity' | 'personality' | 'sources' | 'schedule'>('identity'); const [formData, setFormData] = useState({ @@ -80,6 +82,12 @@ export default function EditBotPage() { fetchBot(); }, [botId]); + useEffect(() => { + if (sourceError) { + setSourceError(''); + } + }, [newSource]); + const fetchBot = async () => { try { const [botRes, sourcesRes] = await Promise.all([ @@ -147,46 +155,94 @@ export default function EditBotPage() { } }; - const handleAddSource = () => { - // Validate based on type - if (newSource.type === 'brave_news') { - if (!newSource.braveQuery || !newSource.apiKey) return; - // Build URL from config + const buildSourcePayload = (source: ContentSource) => { + const payload: Record = { + type: source.type, + url: source.url, + subreddit: source.subreddit, + apiKey: source.apiKey, + }; + + if (source.type === 'brave_news' && source.braveQuery) { const url = new URL('https://api.search.brave.com/res/v1/news/search'); - url.searchParams.set('q', newSource.braveQuery); - if (newSource.braveFreshness) url.searchParams.set('freshness', newSource.braveFreshness); - if (newSource.braveCountry) url.searchParams.set('country', newSource.braveCountry); - setSources([...sources, { ...newSource, url: url.toString() }]); - } else if (newSource.type === 'news_api') { - if (!newSource.newsQuery || !newSource.apiKey) return; - // Build URL from config + url.searchParams.set('q', source.braveQuery); + if (source.braveFreshness) url.searchParams.set('freshness', source.braveFreshness); + if (source.braveCountry) url.searchParams.set('country', source.braveCountry); + payload.url = url.toString(); + payload.braveNewsConfig = { + query: source.braveQuery, + freshness: source.braveFreshness, + country: source.braveCountry || undefined, + }; + } else if (source.type === 'news_api' && source.newsQuery) { let baseUrl: string; const params = new URLSearchParams(); - switch (newSource.newsProvider) { + switch (source.newsProvider) { case 'gnews': baseUrl = 'https://gnews.io/api/v4/search'; - params.set('q', newSource.newsQuery); - if (newSource.newsCountry) params.set('country', newSource.newsCountry); - if (newSource.newsLanguage) params.set('lang', newSource.newsLanguage); - if (newSource.newsCategory) params.set('topic', newSource.newsCategory); + params.set('q', source.newsQuery); + if (source.newsCountry) params.set('country', source.newsCountry); + if (source.newsLanguage) params.set('lang', source.newsLanguage); + if (source.newsCategory) params.set('topic', source.newsCategory); break; case 'newsdata': baseUrl = 'https://newsdata.io/api/1/news'; - params.set('q', newSource.newsQuery); - if (newSource.newsCountry) params.set('country', newSource.newsCountry); - if (newSource.newsLanguage) params.set('language', newSource.newsLanguage); - if (newSource.newsCategory) params.set('category', newSource.newsCategory); + params.set('q', source.newsQuery); + if (source.newsCountry) params.set('country', source.newsCountry); + if (source.newsLanguage) params.set('language', source.newsLanguage); + if (source.newsCategory) params.set('category', source.newsCategory); break; default: baseUrl = 'https://newsapi.org/v2/everything'; - params.set('q', newSource.newsQuery); - if (newSource.newsLanguage) params.set('language', newSource.newsLanguage); + params.set('q', source.newsQuery); + if (source.newsLanguage) params.set('language', source.newsLanguage); } - setSources([...sources, { ...newSource, url: `${baseUrl}?${params.toString()}` }]); - } else { - if (!newSource.url) return; - setSources([...sources, { ...newSource }]); + payload.url = `${baseUrl}?${params.toString()}`; + payload.newsApiConfig = { + provider: source.newsProvider, + query: source.newsQuery, + category: source.newsCategory || undefined, + country: source.newsCountry || undefined, + language: source.newsLanguage || undefined, + }; } + + return { + payload, + source: { + ...source, + url: payload.url as string, + }, + }; + }; + + const handleAddSource = async () => { + const { payload, source } = buildSourcePayload(newSource); + + setSourceError(''); + setValidatingSource(true); + + try { + const response = await fetch('/api/bots/validate-source', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + + const data = await response.json().catch(() => ({})); + if (!response.ok) { + const details = Array.isArray(data.details) ? data.details.join(', ') : ''; + throw new Error(details || data.error || 'Source validation failed'); + } + + setSources([...sources, source]); + } catch (err) { + setSourceError(err instanceof Error ? err.message : 'Source validation failed'); + return; + } finally { + setValidatingSource(false); + } + setNewSource({ type: 'rss', url: '', @@ -695,11 +751,17 @@ export default function EditBotPage() { )} + {sourceError && ( +
+ {sourceError} +
+ )} + diff --git a/src/app/bots/[id]/page.tsx b/src/app/bots/[id]/page.tsx index 6628c32..d7c9ab6 100644 --- a/src/app/bots/[id]/page.tsx +++ b/src/app/bots/[id]/page.tsx @@ -205,7 +205,8 @@ export default function BotDetailPage() { fetchSources(); } else { const data = await response.json(); - showToast(`Failed to add source: ${data.error || 'Unknown error'}`, 'error'); + const details = Array.isArray(data.details) ? ` ${data.details.join(', ')}` : ''; + showToast(`Failed to add source: ${data.error || 'Unknown error'}${details}`, 'error'); } } catch (error) { showToast('Failed to add source', 'error'); diff --git a/src/app/bots/new/page.tsx b/src/app/bots/new/page.tsx index 7004112..18a5ba3 100644 --- a/src/app/bots/new/page.tsx +++ b/src/app/bots/new/page.tsx @@ -39,6 +39,8 @@ export default function NewBotPage() { const router = useRouter(); const [loading, setLoading] = useState(false); const [error, setError] = useState(''); + const [sourceError, setSourceError] = useState(''); + const [validatingSource, setValidatingSource] = useState(false); const [step, setStep] = useState<'identity' | 'personality' | 'sources' | 'schedule'>('identity'); const [formData, setFormData] = useState({ @@ -98,46 +100,100 @@ export default function NewBotPage() { fetchPreviousBotSettings(); }, []); - const handleAddSource = () => { - // Validate based on type - if (newSource.type === 'brave_news') { - if (!newSource.braveQuery || !newSource.apiKey) return; - // Build URL from config + useEffect(() => { + if (sourceError) { + setSourceError(''); + } + }, [newSource]); + + const buildSourcePayload = (source: ContentSource) => { + const payload: Record = { + type: source.type, + url: source.url, + subreddit: source.subreddit, + apiKey: source.apiKey, + }; + + if (source.type === 'brave_news' && source.braveQuery) { const url = new URL('https://api.search.brave.com/res/v1/news/search'); - url.searchParams.set('q', newSource.braveQuery); - if (newSource.braveFreshness) url.searchParams.set('freshness', newSource.braveFreshness); - if (newSource.braveCountry) url.searchParams.set('country', newSource.braveCountry); - setSources([...sources, { ...newSource, url: url.toString() }]); - } else if (newSource.type === 'news_api') { - if (!newSource.newsQuery || !newSource.apiKey) return; - // Build URL from config + url.searchParams.set('q', source.braveQuery); + if (source.braveFreshness) url.searchParams.set('freshness', source.braveFreshness); + if (source.braveCountry) url.searchParams.set('country', source.braveCountry); + payload.url = url.toString(); + payload.braveNewsConfig = { + query: source.braveQuery, + freshness: source.braveFreshness, + country: source.braveCountry || undefined, + }; + } else if (source.type === 'news_api' && source.newsQuery) { let baseUrl: string; const params = new URLSearchParams(); - switch (newSource.newsProvider) { + switch (source.newsProvider) { case 'gnews': baseUrl = 'https://gnews.io/api/v4/search'; - params.set('q', newSource.newsQuery); - if (newSource.newsCountry) params.set('country', newSource.newsCountry); - if (newSource.newsLanguage) params.set('lang', newSource.newsLanguage); - if (newSource.newsCategory) params.set('topic', newSource.newsCategory); + params.set('q', source.newsQuery); + if (source.newsCountry) params.set('country', source.newsCountry); + if (source.newsLanguage) params.set('lang', source.newsLanguage); + if (source.newsCategory) params.set('topic', source.newsCategory); break; case 'newsdata': baseUrl = 'https://newsdata.io/api/1/news'; - params.set('q', newSource.newsQuery); - if (newSource.newsCountry) params.set('country', newSource.newsCountry); - if (newSource.newsLanguage) params.set('language', newSource.newsLanguage); - if (newSource.newsCategory) params.set('category', newSource.newsCategory); + params.set('q', source.newsQuery); + if (source.newsCountry) params.set('country', source.newsCountry); + if (source.newsLanguage) params.set('language', source.newsLanguage); + if (source.newsCategory) params.set('category', source.newsCategory); break; default: baseUrl = 'https://newsapi.org/v2/everything'; - params.set('q', newSource.newsQuery); - if (newSource.newsLanguage) params.set('language', newSource.newsLanguage); + params.set('q', source.newsQuery); + if (source.newsLanguage) params.set('language', source.newsLanguage); } - setSources([...sources, { ...newSource, url: `${baseUrl}?${params.toString()}` }]); - } else { - if (!newSource.url) return; - setSources([...sources, { ...newSource }]); + payload.url = `${baseUrl}?${params.toString()}`; + payload.newsApiConfig = { + provider: source.newsProvider, + query: source.newsQuery, + category: source.newsCategory || undefined, + country: source.newsCountry || undefined, + language: source.newsLanguage || undefined, + }; } + + return { + payload, + source: { + ...source, + url: payload.url as string, + }, + }; + }; + + const handleAddSource = async () => { + const { payload, source } = buildSourcePayload(newSource); + + setSourceError(''); + setValidatingSource(true); + + try { + const response = await fetch('/api/bots/validate-source', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + + const data = await response.json().catch(() => ({})); + if (!response.ok) { + const details = Array.isArray(data.details) ? data.details.join(', ') : ''; + throw new Error(details || data.error || 'Source validation failed'); + } + + setSources([...sources, source]); + } catch (err) { + setSourceError(err instanceof Error ? err.message : 'Source validation failed'); + return; + } finally { + setValidatingSource(false); + } + setNewSource({ type: 'rss', url: '', @@ -648,11 +704,17 @@ export default function NewBotPage() { )} + {sourceError && ( +
+ {sourceError} +
+ )} + diff --git a/src/app/chat/page.tsx b/src/app/chat/page.tsx index 5ec7e5a..a98b741 100644 --- a/src/app/chat/page.tsx +++ b/src/app/chat/page.tsx @@ -4,7 +4,8 @@ import { useState, useEffect, useRef } from 'react'; import { useAuth } from '@/lib/contexts/AuthContext'; import { signedAPI } from '@/lib/api/signed-fetch'; import { ArrowLeft, Send, Loader2, MessageCircle, Search, Plus, Trash2, MoreVertical } from 'lucide-react'; -import { useFormattedHandle } from '@/lib/utils/handle'; +import Link from 'next/link'; +import { getProfilePath, useFormattedHandle } from '@/lib/utils/handle'; import { useRouter, useSearchParams } from 'next/navigation'; interface Conversation { @@ -392,10 +393,20 @@ export default function ChatPage() { )}
-
{selectedConversation.participant2.displayName}
-
- {selectedHandle} -
+ +
{selectedConversation.participant2.displayName}
+
+ {selectedHandle} +
+