Fix reply consistency and source validation

This commit is contained in:
cyph3rasi
2026-03-08 18:29:42 -07:00
parent a8f104ee14
commit 5f21ea8e5d
20 changed files with 545 additions and 276 deletions
+11
View File
@@ -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,
+94
View File
@@ -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 }
);
}
}
+7 -54
View File
@@ -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<string>();
let repostedReplyIds = new Set<string | null>();
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;
+82 -86
View File
@@ -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<number>`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: {
-22
View File
@@ -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<number>`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,
+1 -1
View File
@@ -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,
+21 -15
View File
@@ -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<number>`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 });
+8
View File
@@ -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,
+1 -23
View File
@@ -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<string, number>();
if (swarmPostIds.length > 0) {
const counts = await db.select({
swarmReplyToId: posts.swarmReplyToId,
replyCount: sql<number>`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,
+91 -29
View File
@@ -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<string, unknown> = {
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 && (
<div style={{ fontSize: '13px', color: 'var(--error)' }}>
{sourceError}
</div>
)}
<button
type="button"
onClick={handleAddSource}
className="btn btn-primary"
disabled={
disabled={validatingSource ||
(newSource.type === 'rss' && !newSource.url) ||
(newSource.type === 'reddit' && !newSource.subreddit) ||
(newSource.type === 'youtube' && !newSource.youtubeChannelId && !newSource.youtubePlaylistId) ||
@@ -707,7 +769,7 @@ export default function EditBotPage() {
(newSource.type === 'news_api' && (!newSource.newsQuery || !newSource.apiKey))
}
>
Add Source
{validatingSource ? 'Validating Source...' : 'Add Source'}
</button>
</div>
</div>
+2 -1
View File
@@ -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');
+91 -29
View File
@@ -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<string, unknown> = {
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 && (
<div style={{ fontSize: '13px', color: 'var(--error)' }}>
{sourceError}
</div>
)}
<button
type="button"
onClick={handleAddSource}
className="btn btn-primary"
disabled={
disabled={validatingSource ||
(newSource.type === 'rss' && !newSource.url) ||
(newSource.type === 'reddit' && !newSource.subreddit) ||
(newSource.type === 'youtube' && !newSource.youtubeChannelId && !newSource.youtubePlaylistId) ||
@@ -660,7 +722,7 @@ export default function NewBotPage() {
(newSource.type === 'news_api' && (!newSource.newsQuery || !newSource.apiKey))
}
>
Add Source
{validatingSource ? 'Validating Source...' : 'Add Source'}
</button>
</div>
</div>
+16 -5
View File
@@ -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() {
)}
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontWeight: 600, fontSize: '15px' }}>{selectedConversation.participant2.displayName}</div>
<div style={{ fontSize: '12px', color: 'var(--foreground-tertiary)', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
{selectedHandle}
</div>
<Link
href={getProfilePath(selectedConversation.participant2.handle)}
style={{
display: 'block',
color: 'var(--foreground)',
textDecoration: 'none',
minWidth: 0,
}}
>
<div style={{ fontWeight: 600, fontSize: '15px' }}>{selectedConversation.participant2.displayName}</div>
<div style={{ fontSize: '12px', color: 'var(--foreground-tertiary)', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
{selectedHandle}
</div>
</Link>
</div>
<button
onClick={() => { setConversationToDelete(selectedConversation); setShowDeleteModal(true); }}
+2 -1
View File
@@ -3,6 +3,7 @@
import { useEffect, useMemo, useState } from 'react';
import Link from 'next/link';
import { Bot } from 'lucide-react';
import { getProfilePath } from '@/lib/utils/handle';
type AdminUser = {
id: string;
@@ -440,7 +441,7 @@ export default function ModerationPage() {
)}
</div>
<div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
<Link href={`/@${user.handle}`} className="btn btn-ghost btn-sm">
<Link href={getProfilePath(user.handle)} className="btn btn-ghost btn-sm">
View
</Link>
{user.isSuspended ? (
+4 -2
View File
@@ -4,6 +4,7 @@ import { useState, useEffect } from 'react';
import { BellIcon } from '@/components/Icons';
import Link from 'next/link';
import { useAuth } from '@/lib/contexts/AuthContext';
import { getProfilePath } from '@/lib/utils/handle';
interface NotificationActor {
id: string;
@@ -183,6 +184,7 @@ function NotificationItem({
}) {
const isUnread = !notification.readAt;
const actor = notification.actor;
const actorProfilePath = actor ? getProfilePath(actor.handle) : '#';
return (
<div
@@ -195,7 +197,7 @@ function NotificationItem({
alignItems: 'flex-start',
}}
>
<Link href={actor ? `/@${actor.handle}` : '#'} style={{ flexShrink: 0 }}>
<Link href={actorProfilePath} style={{ flexShrink: 0 }}>
{actor?.avatarUrl ? (
<img
src={actor.avatarUrl}
@@ -227,7 +229,7 @@ function NotificationItem({
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ display: 'flex', gap: '4px', alignItems: 'baseline', flexWrap: 'wrap' }}>
<Link
href={actor ? `/@${actor.handle}` : '#'}
href={actorProfilePath}
style={{ fontWeight: 600, color: 'var(--foreground)', textDecoration: 'none' }}
>
{actor?.displayName || actor?.handle || 'Someone'} <span style={{ fontWeight: 400, color: 'var(--foreground-tertiary)' }}>@{actor?.handle}</span>
+2 -2
View File
@@ -3,7 +3,7 @@
import { useState, useEffect, useCallback } from 'react';
import Link from 'next/link';
import { useSearchParams, useRouter } from 'next/navigation';
import { useFormattedHandle } from '@/lib/utils/handle';
import { getProfilePath, useFormattedHandle } from '@/lib/utils/handle';
import { PostCard } from '@/components/PostCard';
import { Post } from '@/lib/types';
import { Bot } from 'lucide-react';
@@ -70,7 +70,7 @@ function UserCard({ user }: { user: User }) {
const fullHandle = useFormattedHandle(user.handle);
return (
<Link
href={`/@${user.handle}`}
href={getProfilePath(user.handle)}
style={{
display: 'flex',
alignItems: 'center',
+2 -1
View File
@@ -2,6 +2,7 @@
import { useState, useEffect } from 'react';
import Link from 'next/link';
import { getProfilePath } from '@/lib/utils/handle';
import { ArrowLeftIcon } from '@/components/Icons';
import { UserX, Globe, Trash2 } from 'lucide-react';
@@ -179,7 +180,7 @@ export default function ModerationSettingsPage() {
}}
>
<Link
href={`/@${user.handle}`}
href={getProfilePath(user.handle)}
style={{
display: 'flex',
alignItems: 'center',