Fix reply consistency and source validation
This commit is contained in:
@@ -20,6 +20,7 @@ import {
|
|||||||
MAX_KEYWORDS,
|
MAX_KEYWORDS,
|
||||||
MAX_KEYWORD_LENGTH,
|
MAX_KEYWORD_LENGTH,
|
||||||
} from '@/lib/bots/contentSource';
|
} from '@/lib/bots/contentSource';
|
||||||
|
import { validateSourceReachability } from '@/lib/bots/contentSourceValidation';
|
||||||
|
|
||||||
type RouteContext = { params: Promise<{ id: string }> };
|
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 });
|
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
|
// Add the content source
|
||||||
const source = await addSource(botId, {
|
const source = await addSource(botId, {
|
||||||
type: data.type,
|
type: data.type,
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -103,59 +103,7 @@ export async function GET(
|
|||||||
})) || [],
|
})) || [],
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const localSwarmReplyId = `swarm:${originDomain}:${originalPostId}`;
|
mainPost.repliesCount = replyPosts.length;
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if current user has liked this post
|
// Check if current user has liked this post
|
||||||
try {
|
try {
|
||||||
@@ -214,6 +162,11 @@ export async function GET(
|
|||||||
orderBy: [desc(posts.createdAt)],
|
orderBy: [desc(posts.createdAt)],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
mainPost = {
|
||||||
|
...post,
|
||||||
|
repliesCount: replies.length,
|
||||||
|
};
|
||||||
|
|
||||||
let allPostIds = [post.id, ...replies.map(r => r.id)];
|
let allPostIds = [post.id, ...replies.map(r => r.id)];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -243,7 +196,7 @@ export async function GET(
|
|||||||
const repostedPostIds = new Set(viewerReposts.map(r => r.repostOfId));
|
const repostedPostIds = new Set(viewerReposts.map(r => r.repostOfId));
|
||||||
|
|
||||||
mainPost = {
|
mainPost = {
|
||||||
...post,
|
...mainPost,
|
||||||
isLiked: likedPostIds.has(post.id),
|
isLiked: likedPostIds.has(post.id),
|
||||||
isReposted: repostedPostIds.has(post.id),
|
isReposted: repostedPostIds.has(post.id),
|
||||||
} as any;
|
} as any;
|
||||||
|
|||||||
+82
-86
@@ -86,16 +86,92 @@ export async function POST(request: Request) {
|
|||||||
linkPreviewImage: data.linkPreview?.image,
|
linkPreviewImage: data.linkPreview?.image,
|
||||||
}).returning();
|
}).returning();
|
||||||
|
|
||||||
let attachedMedia: typeof media.$inferSelect[] = [];
|
let unattachedMedia: typeof media.$inferSelect[] = [];
|
||||||
if (data.mediaIds?.length) {
|
if (data.mediaIds?.length) {
|
||||||
await db.update(media)
|
unattachedMedia = await db.query.media.findMany({
|
||||||
.set({ postId: post.id })
|
where: and(
|
||||||
.where(and(
|
|
||||||
inArray(media.id, data.mediaIds),
|
inArray(media.id, data.mediaIds),
|
||||||
eq(media.userId, user.id),
|
eq(media.userId, user.id),
|
||||||
isNull(media.postId),
|
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({
|
attachedMedia = await db.query.media.findMany({
|
||||||
where: and(
|
where: and(
|
||||||
inArray(media.id, data.mediaIds),
|
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)
|
// Handle local mentions (create notifications for users on this node)
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -538,26 +554,6 @@ export async function GET(request: Request) {
|
|||||||
includeNsfw,
|
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 => ({
|
const swarmPosts = swarmResult.posts.map(sp => ({
|
||||||
id: `swarm:${sp.nodeDomain}:${sp.id}`,
|
id: `swarm:${sp.nodeDomain}:${sp.id}`,
|
||||||
originalPostId: sp.id, // Keep the original ID for replies
|
originalPostId: sp.id, // Keep the original ID for replies
|
||||||
@@ -565,7 +561,7 @@ export async function GET(request: Request) {
|
|||||||
createdAt: new Date(sp.createdAt),
|
createdAt: new Date(sp.createdAt),
|
||||||
likesCount: sp.likeCount,
|
likesCount: sp.likeCount,
|
||||||
repostsCount: sp.repostCount,
|
repostsCount: sp.repostCount,
|
||||||
repliesCount: sp.replyCount + (localReplyCountMap.get(`swarm:${sp.nodeDomain}:${sp.id}`) || 0),
|
repliesCount: sp.replyCount,
|
||||||
isSwarm: true,
|
isSwarm: true,
|
||||||
nodeDomain: sp.nodeDomain,
|
nodeDomain: sp.nodeDomain,
|
||||||
author: {
|
author: {
|
||||||
|
|||||||
@@ -5,10 +5,8 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { db, posts } from '@/db';
|
|
||||||
import { fetchSwarmTimeline } from '@/lib/swarm/timeline';
|
import { fetchSwarmTimeline } from '@/lib/swarm/timeline';
|
||||||
import { getSession } from '@/lib/auth';
|
import { getSession } from '@/lib/auth';
|
||||||
import { and, eq, inArray, sql } from 'drizzle-orm';
|
|
||||||
import { getViewerSwarmLikedPostIds } from '@/lib/swarm/likes';
|
import { getViewerSwarmLikedPostIds } from '@/lib/swarm/likes';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -20,7 +18,6 @@ import { getViewerSwarmLikedPostIds } from '@/lib/swarm/likes';
|
|||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const { searchParams } = new URL(request.url);
|
const { searchParams } = new URL(request.url);
|
||||||
const refresh = searchParams.get('refresh') === 'true';
|
|
||||||
const cursor = searchParams.get('cursor') || undefined;
|
const cursor = searchParams.get('cursor') || undefined;
|
||||||
|
|
||||||
// Check user's NSFW preference
|
// Check user's NSFW preference
|
||||||
@@ -34,24 +31,6 @@ export async function GET(request: NextRequest) {
|
|||||||
|
|
||||||
// Fetch swarm timeline (no caching - user preferences vary)
|
// Fetch swarm timeline (no caching - user preferences vary)
|
||||||
const timeline = await fetchSwarmTimeline(10, 15, { includeNsfw, cursor });
|
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 session = await getSession().catch(() => null);
|
||||||
const viewer = session?.user;
|
const viewer = session?.user;
|
||||||
@@ -71,7 +50,6 @@ export async function GET(request: NextRequest) {
|
|||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
posts: timeline.posts.map(post => ({
|
posts: timeline.posts.map(post => ({
|
||||||
...post,
|
...post,
|
||||||
replyCount: post.replyCount + (localReplyCountMap.get(`swarm:${post.nodeDomain}:${post.id}`) || 0),
|
|
||||||
isLiked: likedPostIds.has(`swarm:${post.nodeDomain}:${post.id}`),
|
isLiked: likedPostIds.has(`swarm:${post.nodeDomain}:${post.id}`),
|
||||||
})),
|
})),
|
||||||
sources: timeline.sources,
|
sources: timeline.sources,
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ export async function GET(request: NextRequest, context: RouteContext) {
|
|||||||
createdAt: post.createdAt.toISOString(),
|
createdAt: post.createdAt.toISOString(),
|
||||||
likesCount: post.likesCount,
|
likesCount: post.likesCount,
|
||||||
repostsCount: post.repostsCount,
|
repostsCount: post.repostsCount,
|
||||||
repliesCount: post.repliesCount,
|
repliesCount: replies.length,
|
||||||
author: {
|
author: {
|
||||||
handle: author.handle,
|
handle: author.handle,
|
||||||
displayName: author.displayName,
|
displayName: author.displayName,
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
|
|
||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { db, posts, users, media, notifications } from '@/db';
|
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 { z } from 'zod';
|
||||||
import { verifySwarmRequest } from '@/lib/swarm/signature';
|
import { verifySwarmRequest } from '@/lib/swarm/signature';
|
||||||
import { upsertRemoteUser } from '@/lib/swarm/user-cache';
|
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
|
* POST /api/swarm/replies
|
||||||
*
|
*
|
||||||
@@ -129,9 +143,7 @@ export async function POST(request: NextRequest) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
await db.update(posts)
|
await syncParentReplyCount(data.postId);
|
||||||
.set({ repliesCount: parentPost.repliesCount + 1 })
|
|
||||||
.where(eq(posts.id, data.postId));
|
|
||||||
|
|
||||||
if (parentPost.userId !== remoteUser.id) {
|
if (parentPost.userId !== remoteUser.id) {
|
||||||
await db.insert(notifications).values({
|
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' });
|
return NextResponse.json({ success: true, message: 'Reply not found or already deleted' });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Decrement parent's reply count
|
const parentReplyToId = existingReply.replyToId;
|
||||||
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));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Delete the reply
|
// Delete the reply
|
||||||
await db.delete(posts).where(eq(posts.id, existingReply.id));
|
await db.delete(posts).where(eq(posts.id, existingReply.id));
|
||||||
|
|
||||||
|
if (parentReplyToId) {
|
||||||
|
await syncParentReplyCount(parentReplyToId);
|
||||||
|
}
|
||||||
|
|
||||||
console.log(`[Swarm] Deleted reply ${swarmReplyId} from ${nodeDomain}`);
|
console.log(`[Swarm] Deleted reply ${swarmReplyId} from ${nodeDomain}`);
|
||||||
|
|
||||||
return NextResponse.json({ success: true });
|
return NextResponse.json({ success: true });
|
||||||
|
|||||||
@@ -12,6 +12,9 @@ export interface SwarmPost {
|
|||||||
id: string;
|
id: string;
|
||||||
content: string;
|
content: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
|
isReply?: boolean;
|
||||||
|
replyToId?: string | null;
|
||||||
|
swarmReplyToId?: string | null;
|
||||||
author: {
|
author: {
|
||||||
handle: string;
|
handle: string;
|
||||||
displayName: string;
|
displayName: string;
|
||||||
@@ -84,6 +87,8 @@ export async function GET(request: NextRequest) {
|
|||||||
id: posts.id,
|
id: posts.id,
|
||||||
content: posts.content,
|
content: posts.content,
|
||||||
createdAt: posts.createdAt,
|
createdAt: posts.createdAt,
|
||||||
|
replyToId: posts.replyToId,
|
||||||
|
swarmReplyToId: posts.swarmReplyToId,
|
||||||
isNsfw: posts.isNsfw,
|
isNsfw: posts.isNsfw,
|
||||||
likesCount: posts.likesCount,
|
likesCount: posts.likesCount,
|
||||||
repostsCount: posts.repostsCount,
|
repostsCount: posts.repostsCount,
|
||||||
@@ -120,6 +125,9 @@ export async function GET(request: NextRequest) {
|
|||||||
id: post.id,
|
id: post.id,
|
||||||
content: post.content,
|
content: post.content,
|
||||||
createdAt: post.createdAt.toISOString(),
|
createdAt: post.createdAt.toISOString(),
|
||||||
|
isReply: Boolean(post.replyToId || post.swarmReplyToId),
|
||||||
|
replyToId: post.replyToId,
|
||||||
|
swarmReplyToId: post.swarmReplyToId,
|
||||||
author: {
|
author: {
|
||||||
handle: post.authorHandle,
|
handle: post.authorHandle,
|
||||||
displayName: post.authorDisplayName || post.authorHandle,
|
displayName: post.authorDisplayName || post.authorHandle,
|
||||||
|
|||||||
@@ -146,28 +146,6 @@ export async function GET(request: Request, context: RouteContext) {
|
|||||||
avatarUrl: profile.avatarUrl,
|
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) => ({
|
const remotePosts = profileData.posts.map((post: any) => ({
|
||||||
id: post.id,
|
id: post.id,
|
||||||
originalPostId: post.id,
|
originalPostId: post.id,
|
||||||
@@ -175,7 +153,7 @@ export async function GET(request: Request, context: RouteContext) {
|
|||||||
createdAt: post.createdAt,
|
createdAt: post.createdAt,
|
||||||
likesCount: post.likesCount || 0,
|
likesCount: post.likesCount || 0,
|
||||||
repostsCount: post.repostsCount || 0,
|
repostsCount: post.repostsCount || 0,
|
||||||
repliesCount: (post.repliesCount || 0) + (localReplyCounts.get(`swarm:${remote.domain}:${post.id}`) || 0),
|
repliesCount: post.repliesCount || 0,
|
||||||
author,
|
author,
|
||||||
media: post.media || [],
|
media: post.media || [],
|
||||||
linkPreviewUrl: post.linkPreviewUrl || null,
|
linkPreviewUrl: post.linkPreviewUrl || null,
|
||||||
|
|||||||
@@ -46,6 +46,8 @@ export default function EditBotPage() {
|
|||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
|
const [sourceError, setSourceError] = useState('');
|
||||||
|
const [validatingSource, setValidatingSource] = useState(false);
|
||||||
const [step, setStep] = useState<'identity' | 'personality' | 'sources' | 'schedule'>('identity');
|
const [step, setStep] = useState<'identity' | 'personality' | 'sources' | 'schedule'>('identity');
|
||||||
|
|
||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
@@ -80,6 +82,12 @@ export default function EditBotPage() {
|
|||||||
fetchBot();
|
fetchBot();
|
||||||
}, [botId]);
|
}, [botId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (sourceError) {
|
||||||
|
setSourceError('');
|
||||||
|
}
|
||||||
|
}, [newSource]);
|
||||||
|
|
||||||
const fetchBot = async () => {
|
const fetchBot = async () => {
|
||||||
try {
|
try {
|
||||||
const [botRes, sourcesRes] = await Promise.all([
|
const [botRes, sourcesRes] = await Promise.all([
|
||||||
@@ -147,46 +155,94 @@ export default function EditBotPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleAddSource = () => {
|
const buildSourcePayload = (source: ContentSource) => {
|
||||||
// Validate based on type
|
const payload: Record<string, unknown> = {
|
||||||
if (newSource.type === 'brave_news') {
|
type: source.type,
|
||||||
if (!newSource.braveQuery || !newSource.apiKey) return;
|
url: source.url,
|
||||||
// Build URL from config
|
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');
|
const url = new URL('https://api.search.brave.com/res/v1/news/search');
|
||||||
url.searchParams.set('q', newSource.braveQuery);
|
url.searchParams.set('q', source.braveQuery);
|
||||||
if (newSource.braveFreshness) url.searchParams.set('freshness', newSource.braveFreshness);
|
if (source.braveFreshness) url.searchParams.set('freshness', source.braveFreshness);
|
||||||
if (newSource.braveCountry) url.searchParams.set('country', newSource.braveCountry);
|
if (source.braveCountry) url.searchParams.set('country', source.braveCountry);
|
||||||
setSources([...sources, { ...newSource, url: url.toString() }]);
|
payload.url = url.toString();
|
||||||
} else if (newSource.type === 'news_api') {
|
payload.braveNewsConfig = {
|
||||||
if (!newSource.newsQuery || !newSource.apiKey) return;
|
query: source.braveQuery,
|
||||||
// Build URL from config
|
freshness: source.braveFreshness,
|
||||||
|
country: source.braveCountry || undefined,
|
||||||
|
};
|
||||||
|
} else if (source.type === 'news_api' && source.newsQuery) {
|
||||||
let baseUrl: string;
|
let baseUrl: string;
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
switch (newSource.newsProvider) {
|
switch (source.newsProvider) {
|
||||||
case 'gnews':
|
case 'gnews':
|
||||||
baseUrl = 'https://gnews.io/api/v4/search';
|
baseUrl = 'https://gnews.io/api/v4/search';
|
||||||
params.set('q', newSource.newsQuery);
|
params.set('q', source.newsQuery);
|
||||||
if (newSource.newsCountry) params.set('country', newSource.newsCountry);
|
if (source.newsCountry) params.set('country', source.newsCountry);
|
||||||
if (newSource.newsLanguage) params.set('lang', newSource.newsLanguage);
|
if (source.newsLanguage) params.set('lang', source.newsLanguage);
|
||||||
if (newSource.newsCategory) params.set('topic', newSource.newsCategory);
|
if (source.newsCategory) params.set('topic', source.newsCategory);
|
||||||
break;
|
break;
|
||||||
case 'newsdata':
|
case 'newsdata':
|
||||||
baseUrl = 'https://newsdata.io/api/1/news';
|
baseUrl = 'https://newsdata.io/api/1/news';
|
||||||
params.set('q', newSource.newsQuery);
|
params.set('q', source.newsQuery);
|
||||||
if (newSource.newsCountry) params.set('country', newSource.newsCountry);
|
if (source.newsCountry) params.set('country', source.newsCountry);
|
||||||
if (newSource.newsLanguage) params.set('language', newSource.newsLanguage);
|
if (source.newsLanguage) params.set('language', source.newsLanguage);
|
||||||
if (newSource.newsCategory) params.set('category', newSource.newsCategory);
|
if (source.newsCategory) params.set('category', source.newsCategory);
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
baseUrl = 'https://newsapi.org/v2/everything';
|
baseUrl = 'https://newsapi.org/v2/everything';
|
||||||
params.set('q', newSource.newsQuery);
|
params.set('q', source.newsQuery);
|
||||||
if (newSource.newsLanguage) params.set('language', newSource.newsLanguage);
|
if (source.newsLanguage) params.set('language', source.newsLanguage);
|
||||||
}
|
}
|
||||||
setSources([...sources, { ...newSource, url: `${baseUrl}?${params.toString()}` }]);
|
payload.url = `${baseUrl}?${params.toString()}`;
|
||||||
} else {
|
payload.newsApiConfig = {
|
||||||
if (!newSource.url) return;
|
provider: source.newsProvider,
|
||||||
setSources([...sources, { ...newSource }]);
|
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({
|
setNewSource({
|
||||||
type: 'rss',
|
type: 'rss',
|
||||||
url: '',
|
url: '',
|
||||||
@@ -695,11 +751,17 @@ export default function EditBotPage() {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{sourceError && (
|
||||||
|
<div style={{ fontSize: '13px', color: 'var(--error)' }}>
|
||||||
|
{sourceError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleAddSource}
|
onClick={handleAddSource}
|
||||||
className="btn btn-primary"
|
className="btn btn-primary"
|
||||||
disabled={
|
disabled={validatingSource ||
|
||||||
(newSource.type === 'rss' && !newSource.url) ||
|
(newSource.type === 'rss' && !newSource.url) ||
|
||||||
(newSource.type === 'reddit' && !newSource.subreddit) ||
|
(newSource.type === 'reddit' && !newSource.subreddit) ||
|
||||||
(newSource.type === 'youtube' && !newSource.youtubeChannelId && !newSource.youtubePlaylistId) ||
|
(newSource.type === 'youtube' && !newSource.youtubeChannelId && !newSource.youtubePlaylistId) ||
|
||||||
@@ -707,7 +769,7 @@ export default function EditBotPage() {
|
|||||||
(newSource.type === 'news_api' && (!newSource.newsQuery || !newSource.apiKey))
|
(newSource.type === 'news_api' && (!newSource.newsQuery || !newSource.apiKey))
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
Add Source
|
{validatingSource ? 'Validating Source...' : 'Add Source'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -205,7 +205,8 @@ export default function BotDetailPage() {
|
|||||||
fetchSources();
|
fetchSources();
|
||||||
} else {
|
} else {
|
||||||
const data = await response.json();
|
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) {
|
} catch (error) {
|
||||||
showToast('Failed to add source', 'error');
|
showToast('Failed to add source', 'error');
|
||||||
|
|||||||
+91
-29
@@ -39,6 +39,8 @@ export default function NewBotPage() {
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
|
const [sourceError, setSourceError] = useState('');
|
||||||
|
const [validatingSource, setValidatingSource] = useState(false);
|
||||||
const [step, setStep] = useState<'identity' | 'personality' | 'sources' | 'schedule'>('identity');
|
const [step, setStep] = useState<'identity' | 'personality' | 'sources' | 'schedule'>('identity');
|
||||||
|
|
||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
@@ -98,46 +100,100 @@ export default function NewBotPage() {
|
|||||||
fetchPreviousBotSettings();
|
fetchPreviousBotSettings();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleAddSource = () => {
|
useEffect(() => {
|
||||||
// Validate based on type
|
if (sourceError) {
|
||||||
if (newSource.type === 'brave_news') {
|
setSourceError('');
|
||||||
if (!newSource.braveQuery || !newSource.apiKey) return;
|
}
|
||||||
// Build URL from config
|
}, [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');
|
const url = new URL('https://api.search.brave.com/res/v1/news/search');
|
||||||
url.searchParams.set('q', newSource.braveQuery);
|
url.searchParams.set('q', source.braveQuery);
|
||||||
if (newSource.braveFreshness) url.searchParams.set('freshness', newSource.braveFreshness);
|
if (source.braveFreshness) url.searchParams.set('freshness', source.braveFreshness);
|
||||||
if (newSource.braveCountry) url.searchParams.set('country', newSource.braveCountry);
|
if (source.braveCountry) url.searchParams.set('country', source.braveCountry);
|
||||||
setSources([...sources, { ...newSource, url: url.toString() }]);
|
payload.url = url.toString();
|
||||||
} else if (newSource.type === 'news_api') {
|
payload.braveNewsConfig = {
|
||||||
if (!newSource.newsQuery || !newSource.apiKey) return;
|
query: source.braveQuery,
|
||||||
// Build URL from config
|
freshness: source.braveFreshness,
|
||||||
|
country: source.braveCountry || undefined,
|
||||||
|
};
|
||||||
|
} else if (source.type === 'news_api' && source.newsQuery) {
|
||||||
let baseUrl: string;
|
let baseUrl: string;
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
switch (newSource.newsProvider) {
|
switch (source.newsProvider) {
|
||||||
case 'gnews':
|
case 'gnews':
|
||||||
baseUrl = 'https://gnews.io/api/v4/search';
|
baseUrl = 'https://gnews.io/api/v4/search';
|
||||||
params.set('q', newSource.newsQuery);
|
params.set('q', source.newsQuery);
|
||||||
if (newSource.newsCountry) params.set('country', newSource.newsCountry);
|
if (source.newsCountry) params.set('country', source.newsCountry);
|
||||||
if (newSource.newsLanguage) params.set('lang', newSource.newsLanguage);
|
if (source.newsLanguage) params.set('lang', source.newsLanguage);
|
||||||
if (newSource.newsCategory) params.set('topic', newSource.newsCategory);
|
if (source.newsCategory) params.set('topic', source.newsCategory);
|
||||||
break;
|
break;
|
||||||
case 'newsdata':
|
case 'newsdata':
|
||||||
baseUrl = 'https://newsdata.io/api/1/news';
|
baseUrl = 'https://newsdata.io/api/1/news';
|
||||||
params.set('q', newSource.newsQuery);
|
params.set('q', source.newsQuery);
|
||||||
if (newSource.newsCountry) params.set('country', newSource.newsCountry);
|
if (source.newsCountry) params.set('country', source.newsCountry);
|
||||||
if (newSource.newsLanguage) params.set('language', newSource.newsLanguage);
|
if (source.newsLanguage) params.set('language', source.newsLanguage);
|
||||||
if (newSource.newsCategory) params.set('category', newSource.newsCategory);
|
if (source.newsCategory) params.set('category', source.newsCategory);
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
baseUrl = 'https://newsapi.org/v2/everything';
|
baseUrl = 'https://newsapi.org/v2/everything';
|
||||||
params.set('q', newSource.newsQuery);
|
params.set('q', source.newsQuery);
|
||||||
if (newSource.newsLanguage) params.set('language', newSource.newsLanguage);
|
if (source.newsLanguage) params.set('language', source.newsLanguage);
|
||||||
}
|
}
|
||||||
setSources([...sources, { ...newSource, url: `${baseUrl}?${params.toString()}` }]);
|
payload.url = `${baseUrl}?${params.toString()}`;
|
||||||
} else {
|
payload.newsApiConfig = {
|
||||||
if (!newSource.url) return;
|
provider: source.newsProvider,
|
||||||
setSources([...sources, { ...newSource }]);
|
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({
|
setNewSource({
|
||||||
type: 'rss',
|
type: 'rss',
|
||||||
url: '',
|
url: '',
|
||||||
@@ -648,11 +704,17 @@ export default function NewBotPage() {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{sourceError && (
|
||||||
|
<div style={{ fontSize: '13px', color: 'var(--error)' }}>
|
||||||
|
{sourceError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleAddSource}
|
onClick={handleAddSource}
|
||||||
className="btn btn-primary"
|
className="btn btn-primary"
|
||||||
disabled={
|
disabled={validatingSource ||
|
||||||
(newSource.type === 'rss' && !newSource.url) ||
|
(newSource.type === 'rss' && !newSource.url) ||
|
||||||
(newSource.type === 'reddit' && !newSource.subreddit) ||
|
(newSource.type === 'reddit' && !newSource.subreddit) ||
|
||||||
(newSource.type === 'youtube' && !newSource.youtubeChannelId && !newSource.youtubePlaylistId) ||
|
(newSource.type === 'youtube' && !newSource.youtubeChannelId && !newSource.youtubePlaylistId) ||
|
||||||
@@ -660,7 +722,7 @@ export default function NewBotPage() {
|
|||||||
(newSource.type === 'news_api' && (!newSource.newsQuery || !newSource.apiKey))
|
(newSource.type === 'news_api' && (!newSource.newsQuery || !newSource.apiKey))
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
Add Source
|
{validatingSource ? 'Validating Source...' : 'Add Source'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+16
-5
@@ -4,7 +4,8 @@ import { useState, useEffect, useRef } from 'react';
|
|||||||
import { useAuth } from '@/lib/contexts/AuthContext';
|
import { useAuth } from '@/lib/contexts/AuthContext';
|
||||||
import { signedAPI } from '@/lib/api/signed-fetch';
|
import { signedAPI } from '@/lib/api/signed-fetch';
|
||||||
import { ArrowLeft, Send, Loader2, MessageCircle, Search, Plus, Trash2, MoreVertical } from 'lucide-react';
|
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';
|
import { useRouter, useSearchParams } from 'next/navigation';
|
||||||
|
|
||||||
interface Conversation {
|
interface Conversation {
|
||||||
@@ -392,10 +393,20 @@ export default function ChatPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div style={{ flex: 1, minWidth: 0 }}>
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
<div style={{ fontWeight: 600, fontSize: '15px' }}>{selectedConversation.participant2.displayName}</div>
|
<Link
|
||||||
<div style={{ fontSize: '12px', color: 'var(--foreground-tertiary)', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
href={getProfilePath(selectedConversation.participant2.handle)}
|
||||||
{selectedHandle}
|
style={{
|
||||||
</div>
|
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>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => { setConversationToDelete(selectedConversation); setShowDeleteModal(true); }}
|
onClick={() => { setConversationToDelete(selectedConversation); setShowDeleteModal(true); }}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { Bot } from 'lucide-react';
|
import { Bot } from 'lucide-react';
|
||||||
|
import { getProfilePath } from '@/lib/utils/handle';
|
||||||
|
|
||||||
type AdminUser = {
|
type AdminUser = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -440,7 +441,7 @@ export default function ModerationPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
|
<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
|
View
|
||||||
</Link>
|
</Link>
|
||||||
{user.isSuspended ? (
|
{user.isSuspended ? (
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useState, useEffect } from 'react';
|
|||||||
import { BellIcon } from '@/components/Icons';
|
import { BellIcon } from '@/components/Icons';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useAuth } from '@/lib/contexts/AuthContext';
|
import { useAuth } from '@/lib/contexts/AuthContext';
|
||||||
|
import { getProfilePath } from '@/lib/utils/handle';
|
||||||
|
|
||||||
interface NotificationActor {
|
interface NotificationActor {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -183,6 +184,7 @@ function NotificationItem({
|
|||||||
}) {
|
}) {
|
||||||
const isUnread = !notification.readAt;
|
const isUnread = !notification.readAt;
|
||||||
const actor = notification.actor;
|
const actor = notification.actor;
|
||||||
|
const actorProfilePath = actor ? getProfilePath(actor.handle) : '#';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -195,7 +197,7 @@ function NotificationItem({
|
|||||||
alignItems: 'flex-start',
|
alignItems: 'flex-start',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Link href={actor ? `/@${actor.handle}` : '#'} style={{ flexShrink: 0 }}>
|
<Link href={actorProfilePath} style={{ flexShrink: 0 }}>
|
||||||
{actor?.avatarUrl ? (
|
{actor?.avatarUrl ? (
|
||||||
<img
|
<img
|
||||||
src={actor.avatarUrl}
|
src={actor.avatarUrl}
|
||||||
@@ -227,7 +229,7 @@ function NotificationItem({
|
|||||||
<div style={{ flex: 1, minWidth: 0 }}>
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
<div style={{ display: 'flex', gap: '4px', alignItems: 'baseline', flexWrap: 'wrap' }}>
|
<div style={{ display: 'flex', gap: '4px', alignItems: 'baseline', flexWrap: 'wrap' }}>
|
||||||
<Link
|
<Link
|
||||||
href={actor ? `/@${actor.handle}` : '#'}
|
href={actorProfilePath}
|
||||||
style={{ fontWeight: 600, color: 'var(--foreground)', textDecoration: 'none' }}
|
style={{ fontWeight: 600, color: 'var(--foreground)', textDecoration: 'none' }}
|
||||||
>
|
>
|
||||||
{actor?.displayName || actor?.handle || 'Someone'} <span style={{ fontWeight: 400, color: 'var(--foreground-tertiary)' }}>@{actor?.handle}</span>
|
{actor?.displayName || actor?.handle || 'Someone'} <span style={{ fontWeight: 400, color: 'var(--foreground-tertiary)' }}>@{actor?.handle}</span>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react';
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useSearchParams, useRouter } from 'next/navigation';
|
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 { PostCard } from '@/components/PostCard';
|
||||||
import { Post } from '@/lib/types';
|
import { Post } from '@/lib/types';
|
||||||
import { Bot } from 'lucide-react';
|
import { Bot } from 'lucide-react';
|
||||||
@@ -70,7 +70,7 @@ function UserCard({ user }: { user: User }) {
|
|||||||
const fullHandle = useFormattedHandle(user.handle);
|
const fullHandle = useFormattedHandle(user.handle);
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
href={`/@${user.handle}`}
|
href={getProfilePath(user.handle)}
|
||||||
style={{
|
style={{
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
import { getProfilePath } from '@/lib/utils/handle';
|
||||||
import { ArrowLeftIcon } from '@/components/Icons';
|
import { ArrowLeftIcon } from '@/components/Icons';
|
||||||
import { UserX, Globe, Trash2 } from 'lucide-react';
|
import { UserX, Globe, Trash2 } from 'lucide-react';
|
||||||
|
|
||||||
@@ -179,7 +180,7 @@ export default function ModerationSettingsPage() {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Link
|
<Link
|
||||||
href={`/@${user.handle}`}
|
href={getProfilePath(user.handle)}
|
||||||
style={{
|
style={{
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import type { ContentSourceConfig } from './contentSource';
|
||||||
|
import { ContentSourceValidationError, validateContentSourceConfig } from './contentSource';
|
||||||
|
import { fetchRSSFeed, fetchRedditPosts, fetchNewsApi, fetchBraveNews, NetworkError, ParseError } from './contentFetcher';
|
||||||
|
import type { FeedItem } from './rssParser';
|
||||||
|
|
||||||
|
const VALIDATION_TIMEOUT_MS = 10000;
|
||||||
|
|
||||||
|
export async function validateSourceReachability(config: ContentSourceConfig): Promise<void> {
|
||||||
|
const validation = validateContentSourceConfig(config);
|
||||||
|
if (!validation.valid) {
|
||||||
|
throw new ContentSourceValidationError(
|
||||||
|
`Invalid content source configuration: ${validation.errors.join(', ')}`,
|
||||||
|
validation.errors
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
let items: FeedItem[] = [];
|
||||||
|
|
||||||
|
switch (config.type) {
|
||||||
|
case 'rss':
|
||||||
|
case 'youtube':
|
||||||
|
items = await fetchRSSFeed(config.url, { maxItems: 1, timeout: VALIDATION_TIMEOUT_MS });
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'reddit':
|
||||||
|
items = await fetchRedditPosts(
|
||||||
|
config.subreddit || '',
|
||||||
|
{ maxItems: 1, timeout: VALIDATION_TIMEOUT_MS }
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'news_api':
|
||||||
|
items = await fetchNewsApi(
|
||||||
|
config.url,
|
||||||
|
config.apiKey || '',
|
||||||
|
{ maxItems: 1, timeout: VALIDATION_TIMEOUT_MS }
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'brave_news':
|
||||||
|
items = await fetchBraveNews(
|
||||||
|
config.braveNewsConfig || { query: '' },
|
||||||
|
config.apiKey || '',
|
||||||
|
{ maxItems: 1, timeout: VALIDATION_TIMEOUT_MS }
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
items = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!items || items.length === 0) {
|
||||||
|
throw new ContentSourceValidationError(
|
||||||
|
'Source is reachable but returned no items',
|
||||||
|
['Source is reachable but returned no items']
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof ContentSourceValidationError) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error instanceof NetworkError || error instanceof ParseError) {
|
||||||
|
throw new ContentSourceValidationError(
|
||||||
|
`Could not validate source: ${error.message}`,
|
||||||
|
[error.message]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error instanceof Error) {
|
||||||
|
throw new ContentSourceValidationError(
|
||||||
|
`Could not validate source: ${error.message}`,
|
||||||
|
[error.message]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new ContentSourceValidationError(
|
||||||
|
'Could not validate source',
|
||||||
|
['Could not validate source']
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,6 +18,16 @@ interface TimelineOptions {
|
|||||||
cursor?: string; // Timestamp cursor for pagination
|
cursor?: string; // Timestamp cursor for pagination
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isReplyPost(post: SwarmPost): boolean {
|
||||||
|
return Boolean(
|
||||||
|
post.isReply ||
|
||||||
|
post.replyToId ||
|
||||||
|
post.swarmReplyToId ||
|
||||||
|
// Defensive against older or non-conforming node payloads.
|
||||||
|
(post as SwarmPost & { replyTo?: unknown }).replyTo
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Extract the first URL from post content
|
* Extract the first URL from post content
|
||||||
*/
|
*/
|
||||||
@@ -183,16 +193,19 @@ export async function fetchSwarmTimeline(
|
|||||||
const sources: TimelineResult['sources'] = [];
|
const sources: TimelineResult['sources'] = [];
|
||||||
|
|
||||||
for (const result of results) {
|
for (const result of results) {
|
||||||
|
const nonReplyPosts = result.posts.filter(post => !isReplyPost(post));
|
||||||
|
|
||||||
// Filter NSFW posts only if user doesn't want NSFW content
|
// Filter NSFW posts only if user doesn't want NSFW content
|
||||||
// A post is NSFW if it's explicitly marked OR comes from an NSFW node
|
// A post is NSFW if it's explicitly marked OR comes from an NSFW node
|
||||||
const filteredPosts = includeNsfw
|
const filteredPosts = includeNsfw
|
||||||
? result.posts
|
? nonReplyPosts
|
||||||
: result.posts.filter(p => !p.isNsfw && !p.nodeIsNsfw);
|
: nonReplyPosts.filter(p => !p.isNsfw && !p.nodeIsNsfw);
|
||||||
|
|
||||||
// Log filtering details for debugging
|
// Log filtering details for debugging
|
||||||
const nsfwPosts = result.posts.filter(p => p.isNsfw);
|
const nsfwPosts = nonReplyPosts.filter(p => p.isNsfw);
|
||||||
const nodeNsfwPosts = result.posts.filter(p => p.nodeIsNsfw);
|
const nodeNsfwPosts = nonReplyPosts.filter(p => p.nodeIsNsfw);
|
||||||
console.log(`[Swarm Timeline] ${result.domain}: ${result.posts.length} posts fetched, ${nsfwPosts.length} marked NSFW, ${nodeNsfwPosts.length} from NSFW node, ${filteredPosts.length} after filter (includeNsfw: ${includeNsfw})`);
|
const replyPosts = result.posts.length - nonReplyPosts.length;
|
||||||
|
console.log(`[Swarm Timeline] ${result.domain}: ${result.posts.length} posts fetched, ${replyPosts} replies filtered, ${nsfwPosts.length} marked NSFW, ${nodeNsfwPosts.length} from NSFW node, ${filteredPosts.length} after filter (includeNsfw: ${includeNsfw})`);
|
||||||
|
|
||||||
sources.push({
|
sources.push({
|
||||||
domain: result.domain,
|
domain: result.domain,
|
||||||
|
|||||||
@@ -27,6 +27,15 @@ export function formatFullHandle(handle: string, nodeDomain?: string | null): st
|
|||||||
return `@${cleanHandle}@${domain}`;
|
return `@${cleanHandle}@${domain}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getProfilePath(handle: string): string {
|
||||||
|
if (!handle) {
|
||||||
|
return '/u';
|
||||||
|
}
|
||||||
|
|
||||||
|
const cleanHandle = handle.startsWith('@') ? handle.slice(1) : handle;
|
||||||
|
return `/u/${cleanHandle}`;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* React hook that formats a handle using the runtime domain config.
|
* React hook that formats a handle using the runtime domain config.
|
||||||
* Use this in client components instead of formatFullHandle for local handles.
|
* Use this in client components instead of formatFullHandle for local handles.
|
||||||
|
|||||||
Reference in New Issue
Block a user