Complete @mention system with shared parsing, accessible autocomplete, canonical links, moderated local notifications, durable idempotent swarm delivery, navigable remote notifications, and exact-once bot replies
Hop-State: A_06FPG3MP3DEEGFB3AWBG03G Hop-Proposal: R_06FPG3KD4J27QKCSNRBMFF0 Hop-Task: T_06FPFT83JWNP9AP1GRH36VR Hop-Attempt: AT_06FPFT83JZM51ZFSMVPE5QR
This commit is contained in:
@@ -0,0 +1,37 @@
|
||||
CREATE TABLE `mention_deliveries` (
|
||||
`id` text PRIMARY KEY,
|
||||
`interaction_id` text NOT NULL UNIQUE,
|
||||
`post_id` text NOT NULL,
|
||||
`target_handle` text NOT NULL,
|
||||
`target_domain` text NOT NULL,
|
||||
`status` text DEFAULT 'pending' NOT NULL,
|
||||
`attempts` integer DEFAULT 0 NOT NULL,
|
||||
`next_attempt_at` integer DEFAULT (unixepoch()) NOT NULL,
|
||||
`last_attempt_at` integer,
|
||||
`delivered_at` integer,
|
||||
`last_error` text,
|
||||
`created_at` integer DEFAULT (unixepoch()) NOT NULL,
|
||||
`updated_at` integer DEFAULT (unixepoch()) NOT NULL,
|
||||
CONSTRAINT `fk_mention_deliveries_post_id_posts_id_fk` FOREIGN KEY (`post_id`) REFERENCES `posts`(`id`) ON DELETE CASCADE
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE `notifications` ADD `remote_post_id` text;--> statement-breakpoint
|
||||
ALTER TABLE `notifications` ADD `remote_post_domain` text;--> statement-breakpoint
|
||||
ALTER TABLE `notifications` ADD `interaction_id` text;--> statement-breakpoint
|
||||
DELETE FROM `bot_mentions`
|
||||
WHERE `id` IN (
|
||||
SELECT `id` FROM (
|
||||
SELECT
|
||||
`id`,
|
||||
ROW_NUMBER() OVER (
|
||||
PARTITION BY `bot_id`, `post_id`
|
||||
ORDER BY `is_processed` DESC, `created_at` ASC, `id` ASC
|
||||
) AS `duplicate_rank`
|
||||
FROM `bot_mentions`
|
||||
)
|
||||
WHERE `duplicate_rank` > 1
|
||||
);--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `bot_mentions_bot_post_unique_idx` ON `bot_mentions` (`bot_id`,`post_id`);--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `mention_deliveries_target_unique_idx` ON `mention_deliveries` (`post_id`,`target_handle`,`target_domain`);--> statement-breakpoint
|
||||
CREATE INDEX `mention_deliveries_due_idx` ON `mention_deliveries` (`status`,`next_attempt_at`);--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `notifications_interaction_unique_idx` ON `notifications` (`interaction_id`);
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,231 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { and, eq, like, notLike, or } from 'drizzle-orm';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { db, blocks, mutedNodes, mutes, remoteFollows, users } from '@/db';
|
||||
import { requireAuth } from '@/lib/auth';
|
||||
import { discoverNode } from '@/lib/swarm/discovery';
|
||||
import { isSwarmNode } from '@/lib/swarm/interactions';
|
||||
import { getPublicSwarmDomain, normalizeNodeDomain } from '@/lib/swarm/node-domain';
|
||||
import { safeFederationRequest } from '@/lib/swarm/safe-federation-http';
|
||||
import { resolveUserHandle } from '@/lib/swarm/user-handle';
|
||||
import { isValidNodeDomain } from '@/lib/utils/federation';
|
||||
|
||||
const querySchema = z.object({
|
||||
q: z.string().max(280),
|
||||
limit: z.coerce.number().int().min(1).max(12).default(8),
|
||||
});
|
||||
|
||||
const remoteDirectorySchema = z.object({
|
||||
users: z.array(z.object({
|
||||
handle: z.string().min(3).max(30).regex(/^[a-zA-Z0-9_]+$/),
|
||||
displayName: z.string().max(100).nullable(),
|
||||
avatarUrl: z.string().url().nullable(),
|
||||
isBot: z.boolean(),
|
||||
})).max(12),
|
||||
});
|
||||
|
||||
export interface MentionSuggestion {
|
||||
handle: string;
|
||||
displayName: string | null;
|
||||
avatarUrl: string | null;
|
||||
isBot: boolean;
|
||||
isRemote: boolean;
|
||||
nodeDomain: string | null;
|
||||
}
|
||||
|
||||
async function excludedLocalUserIds(viewerId: string): Promise<Set<string>> {
|
||||
const [blockRows, muteRows] = await Promise.all([
|
||||
db.select({ userId: blocks.userId, blockedUserId: blocks.blockedUserId })
|
||||
.from(blocks)
|
||||
.where(or(eq(blocks.userId, viewerId), eq(blocks.blockedUserId, viewerId))),
|
||||
db.select({ mutedUserId: mutes.mutedUserId })
|
||||
.from(mutes)
|
||||
.where(eq(mutes.userId, viewerId)),
|
||||
]);
|
||||
|
||||
const ids = new Set<string>(muteRows.map((row) => row.mutedUserId));
|
||||
for (const row of blockRows) {
|
||||
ids.add(row.userId === viewerId ? row.blockedUserId : row.userId);
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
async function localSuggestions(
|
||||
query: string,
|
||||
limit: number,
|
||||
excludedIds: ReadonlySet<string>,
|
||||
): Promise<MentionSuggestion[]> {
|
||||
const pattern = `${query.toLowerCase()}%`;
|
||||
const rows = await db.select({
|
||||
id: users.id,
|
||||
handle: users.handle,
|
||||
displayName: users.displayName,
|
||||
avatarUrl: users.avatarUrl,
|
||||
isBot: users.isBot,
|
||||
})
|
||||
.from(users)
|
||||
.where(and(
|
||||
or(like(users.handle, pattern), like(users.displayName, pattern)),
|
||||
notLike(users.handle, '%@%'),
|
||||
eq(users.isSuspended, false),
|
||||
eq(users.isSilenced, false),
|
||||
))
|
||||
.limit(Math.min(30, limit + excludedIds.size + 4));
|
||||
|
||||
return rows
|
||||
.filter((row) => !row.handle.includes('@') && !excludedIds.has(row.id))
|
||||
.sort((left, right) => {
|
||||
const leftExact = left.handle.toLowerCase() === query.toLowerCase() ? 0 : 1;
|
||||
const rightExact = right.handle.toLowerCase() === query.toLowerCase() ? 0 : 1;
|
||||
return leftExact - rightExact || left.handle.localeCompare(right.handle);
|
||||
})
|
||||
.slice(0, limit)
|
||||
.map((row) => ({
|
||||
handle: row.handle,
|
||||
displayName: row.displayName,
|
||||
avatarUrl: row.avatarUrl,
|
||||
isBot: row.isBot,
|
||||
isRemote: false,
|
||||
nodeDomain: null,
|
||||
}));
|
||||
}
|
||||
|
||||
async function mutedNodeDomains(viewerId: string): Promise<Set<string>> {
|
||||
const rows = await db.select({ nodeDomain: mutedNodes.nodeDomain })
|
||||
.from(mutedNodes)
|
||||
.where(eq(mutedNodes.userId, viewerId));
|
||||
return new Set(rows.map((row) => normalizeNodeDomain(row.nodeDomain)));
|
||||
}
|
||||
|
||||
async function fetchRemoteSuggestions(
|
||||
handleQuery: string,
|
||||
domain: string,
|
||||
limit: number,
|
||||
): Promise<MentionSuggestion[]> {
|
||||
let known = await isSwarmNode(domain);
|
||||
if (!known) known = (await discoverNode(domain)).success;
|
||||
if (!known) return [];
|
||||
|
||||
const publicDomain = getPublicSwarmDomain(domain);
|
||||
const isDevelopmentLoopback = process.env.NODE_ENV === 'development'
|
||||
&& /^(?:localhost|127\.0\.0\.1)(?::\d{1,5})?$/.test(domain);
|
||||
if (!publicDomain && !isDevelopmentLoopback) return [];
|
||||
|
||||
const protocol = isDevelopmentLoopback ? 'http' : 'https';
|
||||
const url = new URL('/api/swarm/users', `${protocol}://${publicDomain || domain}`);
|
||||
url.searchParams.set('q', handleQuery);
|
||||
url.searchParams.set('limit', String(limit));
|
||||
const response = await safeFederationRequest(url.toString(), {
|
||||
headers: { Accept: 'application/json' },
|
||||
maxResponseBytes: 64 * 1024,
|
||||
timeoutMs: 4_000,
|
||||
});
|
||||
if (response.status < 200 || response.status >= 300) return [];
|
||||
|
||||
const parsed = remoteDirectorySchema.safeParse(response.json());
|
||||
if (!parsed.success) return [];
|
||||
return parsed.data.users.map((user) => ({
|
||||
...user,
|
||||
handle: `${user.handle.toLowerCase()}@${domain}`,
|
||||
isRemote: true,
|
||||
nodeDomain: domain,
|
||||
}));
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const viewer = await requireAuth();
|
||||
const parsed = querySchema.safeParse({
|
||||
q: (request.nextUrl.searchParams.get('q') || '').replace(/^@/, '').trim(),
|
||||
limit: request.nextUrl.searchParams.get('limit') || undefined,
|
||||
});
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: 'Invalid query' }, { status: 400 });
|
||||
}
|
||||
|
||||
const query = parsed.data.q.toLowerCase();
|
||||
const excludedIds = await excludedLocalUserIds(viewer.id);
|
||||
const separator = query.indexOf('@');
|
||||
|
||||
if (separator >= 0) {
|
||||
const handleQuery = query.slice(0, separator);
|
||||
const requestedDomain = query.slice(separator + 1);
|
||||
if (!/^[a-zA-Z0-9_]{0,30}$/.test(handleQuery)
|
||||
|| !isValidNodeDomain(requestedDomain)) {
|
||||
return NextResponse.json({ suggestions: [] });
|
||||
}
|
||||
|
||||
const resolution = resolveUserHandle(`user@${requestedDomain}`);
|
||||
if (resolution.isLocal) {
|
||||
return NextResponse.json({
|
||||
suggestions: await localSuggestions(handleQuery, parsed.data.limit, excludedIds),
|
||||
});
|
||||
}
|
||||
|
||||
const domain = normalizeNodeDomain(requestedDomain);
|
||||
const mutedDomains = await mutedNodeDomains(viewer.id);
|
||||
if (mutedDomains.has(domain)) {
|
||||
return NextResponse.json({ suggestions: [] });
|
||||
}
|
||||
|
||||
const suggestions = await fetchRemoteSuggestions(handleQuery, domain, parsed.data.limit);
|
||||
const cachedRemoteUsers = suggestions.length
|
||||
? await db.select({ id: users.id, handle: users.handle })
|
||||
.from(users)
|
||||
.where(or(...suggestions.map((suggestion) => eq(users.handle, suggestion.handle))))
|
||||
: [];
|
||||
const excludedHandles = new Set(
|
||||
cachedRemoteUsers.filter((user) => excludedIds.has(user.id)).map((user) => user.handle.toLowerCase()),
|
||||
);
|
||||
return NextResponse.json({
|
||||
suggestions: suggestions.filter((suggestion) => !excludedHandles.has(suggestion.handle.toLowerCase())),
|
||||
});
|
||||
}
|
||||
|
||||
const local = await localSuggestions(query, parsed.data.limit, excludedIds);
|
||||
if (local.length >= parsed.data.limit) {
|
||||
return NextResponse.json({ suggestions: local });
|
||||
}
|
||||
|
||||
const mutedDomains = await mutedNodeDomains(viewer.id);
|
||||
const knownRemote = await db.select({
|
||||
handle: remoteFollows.targetHandle,
|
||||
displayName: remoteFollows.displayName,
|
||||
avatarUrl: remoteFollows.avatarUrl,
|
||||
})
|
||||
.from(remoteFollows)
|
||||
.where(and(
|
||||
eq(remoteFollows.followerId, viewer.id),
|
||||
or(
|
||||
like(remoteFollows.targetHandle, `${query}%`),
|
||||
like(remoteFollows.displayName, `${query}%`),
|
||||
),
|
||||
))
|
||||
.limit(parsed.data.limit);
|
||||
|
||||
const seen = new Set(local.map((item) => item.handle.toLowerCase()));
|
||||
const remote = knownRemote.flatMap<MentionSuggestion>((row) => {
|
||||
const parts = row.handle.toLowerCase().split('@');
|
||||
if (parts.length !== 2 || !parts[0] || !parts[1] || mutedDomains.has(normalizeNodeDomain(parts[1]))) return [];
|
||||
if (seen.has(row.handle.toLowerCase())) return [];
|
||||
seen.add(row.handle.toLowerCase());
|
||||
return [{
|
||||
handle: row.handle.toLowerCase(),
|
||||
displayName: row.displayName,
|
||||
avatarUrl: row.avatarUrl,
|
||||
isBot: false,
|
||||
isRemote: true,
|
||||
nodeDomain: parts[1],
|
||||
}];
|
||||
});
|
||||
|
||||
return NextResponse.json({ suggestions: [...local, ...remote].slice(0, parsed.data.limit) });
|
||||
} catch (error) {
|
||||
if (error instanceof Error && ['Unauthorized', 'Authentication required'].includes(error.message)) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
console.error('[Mentions] Suggestion lookup failed:', error);
|
||||
return NextResponse.json({ error: 'Failed to load mention suggestions' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -91,6 +91,9 @@ export async function GET(request: Request) {
|
||||
const payload = rows.map((row) => {
|
||||
const key = row.actorNodeDomain ? `${row.actorHandle}@${row.actorNodeDomain}` : null;
|
||||
const freshProfile = key ? freshProfiles.get(key) : null;
|
||||
const remotePostReference = row.remotePostId && row.remotePostDomain
|
||||
? `swarm:${row.remotePostDomain}:${row.remotePostId}`
|
||||
: null;
|
||||
|
||||
return {
|
||||
id: row.id,
|
||||
@@ -114,10 +117,12 @@ export async function GET(request: Request) {
|
||||
nodeDomain: row.targetNodeDomain,
|
||||
isBot: row.targetIsBot,
|
||||
} : null,
|
||||
post: row.postId ? {
|
||||
id: row.postId,
|
||||
post: row.postId || remotePostReference ? {
|
||||
id: row.postId || remotePostReference!,
|
||||
content: row.post?.content || row.postContent,
|
||||
authorHandle: row.post?.author?.handle || null,
|
||||
authorHandle: row.post?.author?.handle || (row.actorNodeDomain
|
||||
? `${row.actorHandle}@${row.actorNodeDomain}`
|
||||
: row.actorHandle),
|
||||
media: row.post?.media.map((item) => ({
|
||||
url: item.url,
|
||||
mimeType: item.mimeType,
|
||||
|
||||
@@ -14,6 +14,14 @@ vi.mock('@/lib/auth/verify-signature', () => ({
|
||||
requireSignedAction: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/mentions/delivery', () => ({
|
||||
registerPostMentions: vi.fn().mockResolvedValue({
|
||||
localNotifications: 0,
|
||||
remoteQueued: 0,
|
||||
skipped: 0,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@/db', () => ({
|
||||
db: {
|
||||
insert: vi.fn(() => ({
|
||||
@@ -80,8 +88,9 @@ describe('POST /api/posts', () => {
|
||||
},
|
||||
did: 'did:synapsis:test123',
|
||||
handle: 'testuser',
|
||||
timestamp: new Date().toISOString(),
|
||||
signature: 'test-signature',
|
||||
ts: Date.now(),
|
||||
nonce: 'nonce-1',
|
||||
sig: 'test-signature',
|
||||
};
|
||||
|
||||
// Create a mock request
|
||||
@@ -118,8 +127,9 @@ describe('POST /api/posts', () => {
|
||||
},
|
||||
did: 'did:synapsis:test123',
|
||||
handle: 'testuser',
|
||||
timestamp: new Date().toISOString(),
|
||||
signature: 'invalid-signature',
|
||||
ts: Date.now(),
|
||||
nonce: 'nonce-2',
|
||||
sig: 'invalid-signature',
|
||||
};
|
||||
|
||||
const request = new Request('http://localhost:43821/api/posts', {
|
||||
@@ -148,8 +158,9 @@ describe('POST /api/posts', () => {
|
||||
},
|
||||
did: 'did:synapsis:nonexistent',
|
||||
handle: 'nonexistent',
|
||||
timestamp: new Date().toISOString(),
|
||||
signature: 'test-signature',
|
||||
ts: Date.now(),
|
||||
nonce: 'nonce-3',
|
||||
sig: 'test-signature',
|
||||
};
|
||||
|
||||
const request = new Request('http://localhost:43821/api/posts', {
|
||||
@@ -178,8 +189,9 @@ describe('POST /api/posts', () => {
|
||||
},
|
||||
did: 'did:synapsis:test123',
|
||||
handle: 'wronghandle',
|
||||
timestamp: new Date().toISOString(),
|
||||
signature: 'test-signature',
|
||||
ts: Date.now(),
|
||||
nonce: 'nonce-4',
|
||||
sig: 'test-signature',
|
||||
};
|
||||
|
||||
const request = new Request('http://localhost:43821/api/posts', {
|
||||
@@ -208,8 +220,9 @@ describe('POST /api/posts', () => {
|
||||
},
|
||||
did: 'did:synapsis:test123',
|
||||
handle: 'testuser',
|
||||
timestamp: new Date(Date.now() - 10 * 60 * 1000).toISOString(), // 10 minutes ago
|
||||
signature: 'test-signature',
|
||||
ts: Date.now() - 10 * 60 * 1000,
|
||||
nonce: 'nonce-5',
|
||||
sig: 'test-signature',
|
||||
};
|
||||
|
||||
const request = new Request('http://localhost:43821/api/posts', {
|
||||
@@ -249,8 +262,9 @@ describe('POST /api/posts', () => {
|
||||
},
|
||||
did: 'did:synapsis:test123',
|
||||
handle: 'testuser',
|
||||
timestamp: new Date().toISOString(),
|
||||
signature: 'test-signature',
|
||||
ts: Date.now(),
|
||||
nonce: 'nonce-6',
|
||||
sig: 'test-signature',
|
||||
};
|
||||
|
||||
const request = new Request('http://localhost:43821/api/posts', {
|
||||
@@ -289,8 +303,9 @@ describe('POST /api/posts', () => {
|
||||
},
|
||||
did: 'did:synapsis:test123',
|
||||
handle: 'testuser',
|
||||
timestamp: new Date().toISOString(),
|
||||
signature: 'test-signature',
|
||||
ts: Date.now(),
|
||||
nonce: 'nonce-7',
|
||||
sig: 'test-signature',
|
||||
};
|
||||
|
||||
const request = new Request('http://localhost:43821/api/posts', {
|
||||
|
||||
+15
-73
@@ -16,6 +16,7 @@ import {
|
||||
CURATED_FEED_WINDOW_HOURS,
|
||||
rankCuratedFeed,
|
||||
} from '@/lib/posts/curated-feed';
|
||||
import { registerPostMentions } from '@/lib/mentions/delivery';
|
||||
|
||||
const POST_MAX_LENGTH = 600;
|
||||
const CURATION_SEED_MULTIPLIER = 5;
|
||||
@@ -406,85 +407,26 @@ export async function POST(request: Request) {
|
||||
}
|
||||
}
|
||||
|
||||
// Handle local mentions (create notifications for users on this node)
|
||||
(async () => {
|
||||
// Resolve local mentions and durably enqueue federated delivery before
|
||||
// returning. Remote network I/O is retried from the persistent outbox.
|
||||
try {
|
||||
const { extractMentions } = await import('@/lib/swarm/interactions');
|
||||
const mentions = extractMentions(postContent);
|
||||
|
||||
for (const mention of mentions) {
|
||||
// Only handle local mentions (no domain)
|
||||
if (mention.domain) continue;
|
||||
|
||||
// Find the mentioned user
|
||||
const mentionedUser = await db.query.users.findFirst({
|
||||
where: { handle: mention.handle.toLowerCase() },
|
||||
});
|
||||
|
||||
if (mentionedUser && mentionedUser.id !== user.id && !mentionedUser.isSuspended) {
|
||||
// Create notification for the mentioned user with actor info stored directly
|
||||
await db.insert(notifications).values({
|
||||
userId: mentionedUser.id,
|
||||
actorId: user.id,
|
||||
actorHandle: user.handle,
|
||||
actorDisplayName: user.displayName,
|
||||
actorAvatarUrl: user.avatarUrl,
|
||||
actorNodeDomain: null, // Local user
|
||||
await registerPostMentions({
|
||||
postId: post.id,
|
||||
postContent: post.content?.slice(0, 200) || null,
|
||||
...(mentionedUser.isBot ? buildNotificationTarget(mentionedUser) : {}),
|
||||
type: 'mention',
|
||||
content: postContent,
|
||||
actor: {
|
||||
id: user.id,
|
||||
handle: user.handle,
|
||||
displayName: user.displayName,
|
||||
avatarUrl: user.avatarUrl,
|
||||
did: user.did,
|
||||
publicKey: user.publicKey,
|
||||
},
|
||||
nodeDomain,
|
||||
});
|
||||
|
||||
// Also notify bot owner if this is a bot being mentioned
|
||||
if (mentionedUser.isBot && mentionedUser.botOwnerId) {
|
||||
await db.insert(notifications).values({
|
||||
userId: mentionedUser.botOwnerId,
|
||||
actorId: user.id,
|
||||
actorHandle: user.handle,
|
||||
actorDisplayName: user.displayName,
|
||||
actorAvatarUrl: user.avatarUrl,
|
||||
actorNodeDomain: null,
|
||||
postId: post.id,
|
||||
postContent: post.content?.slice(0, 200) || null,
|
||||
...buildNotificationTarget(mentionedUser),
|
||||
type: 'mention',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
// Log error with context but don't fail the request - mention notifications are best-effort
|
||||
console.error('[Posts] Error creating mention notifications:', err);
|
||||
console.error('[Posts] Error registering mentions:', err);
|
||||
console.error('[Posts] Context:', { postId: post.id, userId: user.id, content: postContent.slice(0, 100) });
|
||||
}
|
||||
})();
|
||||
|
||||
// SWARM-FIRST: Deliver mentions to swarm nodes
|
||||
(async () => {
|
||||
try {
|
||||
const { deliverSwarmMentions } = await import('@/lib/swarm/interactions');
|
||||
|
||||
const result = await deliverSwarmMentions(
|
||||
postContent,
|
||||
post.id,
|
||||
{
|
||||
handle: user.handle,
|
||||
displayName: user.displayName || user.handle,
|
||||
avatarUrl: user.avatarUrl || undefined,
|
||||
nodeDomain,
|
||||
}
|
||||
);
|
||||
|
||||
if (result.delivered > 0) {
|
||||
console.log(`[Swarm] Delivered ${result.delivered} mentions (${result.failed} failed)`);
|
||||
}
|
||||
} catch (err) {
|
||||
// Log error with context but don't fail the request - swarm delivery is best-effort
|
||||
console.error('[Posts] Error delivering swarm mentions:', err);
|
||||
console.error('[Posts] Context:', { postId: post.id, userId: user.id, nodeDomain });
|
||||
}
|
||||
})();
|
||||
|
||||
// Federate the post to remote followers (non-blocking)
|
||||
(async () => {
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
verifySwarmRequest: vi.fn(),
|
||||
usersFindFirst: vi.fn(),
|
||||
mutedNodeFindFirst: vi.fn(),
|
||||
blockFindFirst: vi.fn(),
|
||||
muteFindFirst: vi.fn(),
|
||||
notificationValues: vi.fn(),
|
||||
notificationReturning: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/swarm/signature', () => ({
|
||||
verifySwarmRequest: mocks.verifySwarmRequest,
|
||||
}));
|
||||
|
||||
vi.mock('@/db', () => ({
|
||||
notifications: { id: 'id' },
|
||||
db: {
|
||||
query: {
|
||||
users: { findFirst: mocks.usersFindFirst },
|
||||
mutedNodes: { findFirst: mocks.mutedNodeFindFirst },
|
||||
blocks: { findFirst: mocks.blockFindFirst },
|
||||
mutes: { findFirst: mocks.muteFindFirst },
|
||||
},
|
||||
insert: vi.fn(() => ({
|
||||
values: (values: unknown) => {
|
||||
mocks.notificationValues(values);
|
||||
return {
|
||||
onConflictDoNothing: () => ({
|
||||
returning: mocks.notificationReturning,
|
||||
then: (resolve: (value: unknown) => unknown) => Promise.resolve(undefined).then(resolve),
|
||||
}),
|
||||
};
|
||||
},
|
||||
})),
|
||||
},
|
||||
}));
|
||||
|
||||
import { POST } from './route';
|
||||
|
||||
const interactionId = '550e8400-e29b-41d4-a716-446655440000';
|
||||
const postId = '6ba7b810-9dad-41d1-80b4-00c04fd430c8';
|
||||
|
||||
function request() {
|
||||
return new Request('https://local.example/api/swarm/interactions/mention', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
mentionedHandle: 'localuser',
|
||||
mention: {
|
||||
actorHandle: 'remoteuser',
|
||||
actorDisplayName: 'Remote User',
|
||||
actorNodeDomain: 'remote.example',
|
||||
postId,
|
||||
postContent: 'Hello @localuser@local.example',
|
||||
interactionId,
|
||||
timestamp: '2026-07-15T22:00:00.000Z',
|
||||
},
|
||||
signature: 'signed',
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
describe('swarm mention receiver', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.verifySwarmRequest.mockResolvedValue(true);
|
||||
mocks.usersFindFirst
|
||||
.mockResolvedValueOnce({
|
||||
id: 'recipient-id',
|
||||
handle: 'localuser',
|
||||
isSuspended: false,
|
||||
isBot: false,
|
||||
botOwnerId: null,
|
||||
})
|
||||
.mockResolvedValueOnce(null);
|
||||
mocks.mutedNodeFindFirst.mockResolvedValue(null);
|
||||
mocks.blockFindFirst.mockResolvedValue(null);
|
||||
mocks.muteFindFirst.mockResolvedValue(null);
|
||||
mocks.notificationReturning.mockResolvedValue([{ id: 'notification-id' }]);
|
||||
});
|
||||
|
||||
it('stores an idempotent, navigable remote post reference', async () => {
|
||||
const response = await POST(request() as never);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(mocks.notificationValues).toHaveBeenCalledWith(expect.objectContaining({
|
||||
userId: 'recipient-id',
|
||||
interactionId: `mention:remote:remote.example:${interactionId}`,
|
||||
remotePostId: postId,
|
||||
remotePostDomain: 'remote.example',
|
||||
actorNodeDomain: 'remote.example',
|
||||
type: 'mention',
|
||||
}));
|
||||
expect(mocks.notificationReturning).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('acknowledges but suppresses a mention from a muted node', async () => {
|
||||
mocks.mutedNodeFindFirst.mockResolvedValue({ id: 'mute-id' });
|
||||
|
||||
const response = await POST(request() as never);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(await response.json()).toMatchObject({ success: true });
|
||||
expect(mocks.notificationValues).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects an invalid node signature before resolving the recipient', async () => {
|
||||
mocks.verifySwarmRequest.mockResolvedValue(false);
|
||||
|
||||
const response = await POST(request() as never);
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(mocks.usersFindFirst).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -7,12 +7,12 @@
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { db, users, notifications } from '@/db';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { db, notifications } from '@/db';
|
||||
import { z } from 'zod';
|
||||
import { verifySwarmRequest } from '@/lib/swarm/signature';
|
||||
import { localHandleSchema, nodeDomainSchema } from '@/lib/utils/federation';
|
||||
import { buildNotificationTarget } from '@/lib/notifications';
|
||||
import { normalizeNodeDomain } from '@/lib/swarm/node-domain';
|
||||
|
||||
const swarmMentionSchema = z.object({
|
||||
mentionedHandle: localHandleSchema,
|
||||
@@ -21,6 +21,8 @@ const swarmMentionSchema = z.object({
|
||||
actorDisplayName: z.string().min(1).max(50),
|
||||
actorAvatarUrl: z.string().url().optional(),
|
||||
actorNodeDomain: nodeDomainSchema,
|
||||
actorDid: z.string().min(1).max(500).optional(),
|
||||
actorPublicKey: z.string().min(1).max(5000).optional(),
|
||||
postId: z.string().uuid(),
|
||||
postContent: z.string().max(10000),
|
||||
interactionId: z.string().uuid(),
|
||||
@@ -29,6 +31,36 @@ const swarmMentionSchema = z.object({
|
||||
signature: z.string(),
|
||||
});
|
||||
|
||||
async function acceptsRemoteMention(
|
||||
userId: string,
|
||||
actorDomain: string,
|
||||
cachedActorId: string | null,
|
||||
): Promise<boolean> {
|
||||
const nodeMute = await db.query.mutedNodes.findFirst({
|
||||
where: { AND: [{ userId }, { nodeDomain: actorDomain }] },
|
||||
columns: { id: true },
|
||||
});
|
||||
if (nodeMute) return false;
|
||||
if (!cachedActorId) return true;
|
||||
|
||||
const [block, mute] = await Promise.all([
|
||||
db.query.blocks.findFirst({
|
||||
where: {
|
||||
OR: [
|
||||
{ AND: [{ userId }, { blockedUserId: cachedActorId }] },
|
||||
{ AND: [{ userId: cachedActorId }, { blockedUserId: userId }] },
|
||||
],
|
||||
},
|
||||
columns: { id: true },
|
||||
}),
|
||||
db.query.mutes.findFirst({
|
||||
where: { AND: [{ userId }, { mutedUserId: cachedActorId }] },
|
||||
columns: { id: true },
|
||||
}),
|
||||
]);
|
||||
return !block && !mute;
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/swarm/interactions/mention
|
||||
*
|
||||
@@ -65,39 +97,53 @@ export async function POST(request: NextRequest) {
|
||||
return NextResponse.json({ error: 'User not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
// Create notification with actor info stored directly
|
||||
try {
|
||||
await db.insert(notifications).values({
|
||||
const actorDomain = normalizeNodeDomain(data.mention.actorNodeDomain);
|
||||
const interactionKey = `mention:remote:${actorDomain}:${data.mention.interactionId}`;
|
||||
const cachedActor = await db.query.users.findFirst({
|
||||
where: { handle: `${data.mention.actorHandle.toLowerCase()}@${actorDomain}` },
|
||||
columns: { id: true },
|
||||
});
|
||||
if (!(await acceptsRemoteMention(mentionedUser.id, actorDomain, cachedActor?.id || null))) {
|
||||
// Do not disclose moderation state to the sending node.
|
||||
return NextResponse.json({ success: true, message: 'Mention received' });
|
||||
}
|
||||
|
||||
// The interaction ID is persisted under a unique index. A retried signed
|
||||
// request therefore remains successful without creating duplicates.
|
||||
const inserted = await db.insert(notifications).values({
|
||||
userId: mentionedUser.id,
|
||||
actorHandle: data.mention.actorHandle,
|
||||
actorDisplayName: data.mention.actorDisplayName,
|
||||
actorAvatarUrl: data.mention.actorAvatarUrl || null,
|
||||
actorNodeDomain: data.mention.actorNodeDomain,
|
||||
actorNodeDomain: actorDomain,
|
||||
remotePostId: data.mention.postId,
|
||||
remotePostDomain: actorDomain,
|
||||
postContent: data.mention.postContent.slice(0, 200),
|
||||
interactionId: interactionKey,
|
||||
...(mentionedUser.isBot ? buildNotificationTarget(mentionedUser) : {}),
|
||||
type: 'mention',
|
||||
});
|
||||
}).onConflictDoNothing().returning({ id: notifications.id });
|
||||
if (inserted.length > 0) {
|
||||
console.log(`[Swarm] Created mention notification for @${data.mentionedHandle} from ${data.mention.actorHandle}@${data.mention.actorNodeDomain}`);
|
||||
} catch (notifError) {
|
||||
console.error(`[Swarm] Failed to create mention notification:`, notifError);
|
||||
}
|
||||
|
||||
// Also notify bot owner if this is a bot being mentioned
|
||||
if (mentionedUser.isBot && mentionedUser.botOwnerId) {
|
||||
try {
|
||||
if (mentionedUser.isBot
|
||||
&& mentionedUser.botOwnerId
|
||||
&& await acceptsRemoteMention(mentionedUser.botOwnerId, actorDomain, cachedActor?.id || null)) {
|
||||
await db.insert(notifications).values({
|
||||
userId: mentionedUser.botOwnerId,
|
||||
actorHandle: data.mention.actorHandle,
|
||||
actorDisplayName: data.mention.actorDisplayName,
|
||||
actorAvatarUrl: data.mention.actorAvatarUrl || null,
|
||||
actorNodeDomain: data.mention.actorNodeDomain,
|
||||
actorNodeDomain: actorDomain,
|
||||
remotePostId: data.mention.postId,
|
||||
remotePostDomain: actorDomain,
|
||||
postContent: data.mention.postContent.slice(0, 200),
|
||||
interactionId: `${interactionKey}:owner:${mentionedUser.id}`,
|
||||
...buildNotificationTarget(mentionedUser),
|
||||
type: 'mention',
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[Swarm] Failed to notify bot owner:', err);
|
||||
}
|
||||
}).onConflictDoNothing();
|
||||
}
|
||||
|
||||
console.log(`[Swarm] Received mention from ${data.mention.actorHandle}@${data.mention.actorNodeDomain} for @${data.mentionedHandle}`);
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { and, asc, eq, like, notLike } from 'drizzle-orm';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { db, users } from '@/db';
|
||||
|
||||
const querySchema = z.object({
|
||||
q: z.string().max(30).regex(/^[a-zA-Z0-9_]*$/),
|
||||
limit: z.coerce.number().int().min(1).max(12).default(8),
|
||||
});
|
||||
|
||||
/** Public, bounded local user directory used by federated mention typeahead. */
|
||||
export async function GET(request: NextRequest) {
|
||||
const parsed = querySchema.safeParse({
|
||||
q: request.nextUrl.searchParams.get('q') || '',
|
||||
limit: request.nextUrl.searchParams.get('limit') || undefined,
|
||||
});
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: 'Invalid query' }, { status: 400 });
|
||||
}
|
||||
|
||||
const query = parsed.data.q.toLowerCase();
|
||||
const matches = await db.select({
|
||||
handle: users.handle,
|
||||
displayName: users.displayName,
|
||||
avatarUrl: users.avatarUrl,
|
||||
isBot: users.isBot,
|
||||
})
|
||||
.from(users)
|
||||
.where(and(
|
||||
like(users.handle, `${query}%`),
|
||||
notLike(users.handle, '%@%'),
|
||||
eq(users.isSuspended, false),
|
||||
eq(users.isSilenced, false),
|
||||
))
|
||||
.orderBy(asc(users.handle))
|
||||
.limit(parsed.data.limit);
|
||||
|
||||
return NextResponse.json({ users: matches });
|
||||
}
|
||||
@@ -385,6 +385,23 @@ a.btn-primary:visited {
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
.post-content a,
|
||||
.post-content .mention-link {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.mention-link {
|
||||
color: var(--accent);
|
||||
font-weight: 550;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.mention-link:hover,
|
||||
.mention-link:focus-visible {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.repost-event-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -759,6 +776,7 @@ a.btn-primary:visited {
|
||||
.compose {
|
||||
padding: 16px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.compose-input {
|
||||
@@ -780,6 +798,101 @@ a.btn-primary:visited {
|
||||
color: var(--foreground-tertiary);
|
||||
}
|
||||
|
||||
.compose-mention-field {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.compose-mention-suggestions {
|
||||
position: absolute;
|
||||
z-index: 40;
|
||||
top: calc(100% + 4px);
|
||||
left: 8px;
|
||||
right: 8px;
|
||||
max-height: 288px;
|
||||
overflow-y: auto;
|
||||
padding: 6px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--background);
|
||||
box-shadow: 0 16px 40px rgba(0, 0, 0, 0.28);
|
||||
}
|
||||
|
||||
.compose-mention-suggestion {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 9px 10px;
|
||||
border: 0;
|
||||
border-radius: var(--radius-sm);
|
||||
background: transparent;
|
||||
color: var(--foreground);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.compose-mention-suggestion:hover,
|
||||
.compose-mention-suggestion.selected {
|
||||
background: var(--background-secondary);
|
||||
}
|
||||
|
||||
.compose-mention-avatar {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
flex: 0 0 34px;
|
||||
overflow: hidden;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.compose-mention-avatar img,
|
||||
.compose-mention-avatar .avatar-fallback {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.compose-mention-identity {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.compose-mention-name,
|
||||
.compose-mention-handle {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.compose-mention-name {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.compose-mention-handle,
|
||||
.compose-mention-loading {
|
||||
color: var(--foreground-tertiary);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.compose-mention-bot {
|
||||
padding: 1px 5px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 999px;
|
||||
color: var(--foreground-secondary);
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.compose-mention-loading {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.compose-footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef, TextareaHTMLAttributes } from 'react';
|
||||
import { forwardRef, useEffect, useImperativeHandle, useRef, TextareaHTMLAttributes } from 'react';
|
||||
|
||||
export default function AutoTextarea(props: TextareaHTMLAttributes<HTMLTextAreaElement>) {
|
||||
const AutoTextarea = forwardRef<HTMLTextAreaElement, TextareaHTMLAttributes<HTMLTextAreaElement>>(function AutoTextarea(props, forwardedRef) {
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
useImperativeHandle(forwardedRef, () => textareaRef.current as HTMLTextAreaElement, []);
|
||||
|
||||
const adjustHeight = () => {
|
||||
const textarea = textareaRef.current;
|
||||
if (textarea) {
|
||||
@@ -35,4 +37,6 @@ export default function AutoTextarea(props: TextareaHTMLAttributes<HTMLTextAreaE
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
export default AutoTextarea;
|
||||
|
||||
+174
-13
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { useState, useEffect, useId, useRef } from 'react';
|
||||
import AutoTextarea from '@/components/AutoTextarea';
|
||||
import { Post, Attachment } from '@/lib/types';
|
||||
import { AlertTriangle, Music2, Paperclip } from 'lucide-react';
|
||||
@@ -10,14 +10,31 @@ import { useAuth } from '@/lib/contexts/AuthContext';
|
||||
import { StorageConfigurationPrompt } from '@/components/StorageConfigurationPrompt';
|
||||
import { getStorageProvider, MediaUploadError, uploadMediaFile } from '@/lib/stuffbox/browser-upload';
|
||||
import { getMediaKind } from '@/lib/media/upload-policy';
|
||||
import { AvatarImage } from '@/components/AvatarImage';
|
||||
import type { LinkPreviewData } from '@/lib/media/linkPreview';
|
||||
import {
|
||||
getActiveMentionQuery,
|
||||
parseMentions,
|
||||
replaceMentionQuery,
|
||||
type ActiveMentionQuery,
|
||||
} from '@/lib/mentions/parser';
|
||||
|
||||
interface MediaAttachment extends Attachment {
|
||||
mimeType?: string;
|
||||
filename?: string;
|
||||
}
|
||||
|
||||
interface MentionSuggestion {
|
||||
handle: string;
|
||||
displayName: string | null;
|
||||
avatarUrl: string | null;
|
||||
isBot: boolean;
|
||||
isRemote: boolean;
|
||||
nodeDomain: string | null;
|
||||
}
|
||||
|
||||
interface ComposeProps {
|
||||
onPost: (content: string, mediaIds: string[], linkPreview?: any, replyToId?: string, isNsfw?: boolean) => void | boolean | Promise<void | boolean>;
|
||||
onPost: (content: string, mediaIds: string[], linkPreview?: LinkPreviewData, replyToId?: string, isNsfw?: boolean) => void | boolean | Promise<void | boolean>;
|
||||
onPosted?: () => void;
|
||||
replyingTo?: Post | null;
|
||||
onCancelReply?: () => void;
|
||||
@@ -28,7 +45,7 @@ interface ComposeProps {
|
||||
|
||||
export function Compose({ onPost, onPosted, replyingTo, onCancelReply, placeholder = "What's happening?", isReply, autoFocus = false }: ComposeProps) {
|
||||
const { isIdentityUnlocked } = useAuth();
|
||||
const replyToHandle = replyingTo ? useFormattedHandle(replyingTo.author.handle) : '';
|
||||
const replyToHandle = useFormattedHandle(replyingTo?.author.handle || '');
|
||||
const [content, setContent] = useState('');
|
||||
const [isPosting, setIsPosting] = useState(false);
|
||||
const [attachments, setAttachments] = useState<MediaAttachment[]>([]);
|
||||
@@ -38,13 +55,18 @@ export function Compose({ onPost, onPosted, replyingTo, onCancelReply, placehold
|
||||
const [storageNotice, setStorageNotice] = useState<string | null>(null);
|
||||
const [pendingStorageFiles, setPendingStorageFiles] = useState<File[]>([]);
|
||||
const [showStorageConfiguration, setShowStorageConfiguration] = useState(false);
|
||||
const [linkPreview, setLinkPreview] = useState<any>(null);
|
||||
const [fetchingPreview, setFetchingPreview] = useState(false);
|
||||
const [linkPreview, setLinkPreview] = useState<LinkPreviewData | null>(null);
|
||||
const [lastDetectedUrl, setLastDetectedUrl] = useState<string | null>(null);
|
||||
const [isNsfw, setIsNsfw] = useState(false);
|
||||
const [canPostNsfw, setCanPostNsfw] = useState(false);
|
||||
const [isNsfwNode, setIsNsfwNode] = useState(false);
|
||||
const mediaInputRef = useRef<HTMLInputElement>(null);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const mentionListId = useId();
|
||||
const [activeMention, setActiveMention] = useState<ActiveMentionQuery | null>(null);
|
||||
const [mentionSuggestions, setMentionSuggestions] = useState<MentionSuggestion[]>([]);
|
||||
const [mentionLoading, setMentionLoading] = useState(false);
|
||||
const [selectedMentionIndex, setSelectedMentionIndex] = useState(0);
|
||||
const maxLength = 600;
|
||||
const remaining = maxLength - content.length;
|
||||
const canSubmit = content.trim().length > 0 || attachments.length > 0;
|
||||
@@ -80,10 +102,16 @@ export function Compose({ onPost, onPosted, replyingTo, onCancelReply, placehold
|
||||
// Detect URLs in content
|
||||
useEffect(() => {
|
||||
const urlRegex = /(?:https?:\/\/)?((?:[a-zA-Z0-9-]+\.)+[a-z]{2,63})\b([-a-zA-Z0-9@:%_\+.~#?&//=]*)/gi;
|
||||
const matches = content.match(urlRegex);
|
||||
const mentionRanges = parseMentions(content);
|
||||
const matches = Array.from(content.matchAll(urlRegex))
|
||||
.filter((match) => {
|
||||
const start = match.index || 0;
|
||||
const end = start + match[0].length;
|
||||
return !mentionRanges.some((mention) => start < mention.end && end > mention.start);
|
||||
});
|
||||
|
||||
if (matches && matches[0]) {
|
||||
const url = matches[0];
|
||||
if (matches[0]) {
|
||||
const url = matches[0][0];
|
||||
if (url !== lastDetectedUrl) {
|
||||
setLastDetectedUrl(url);
|
||||
fetchPreview(url);
|
||||
@@ -94,8 +122,83 @@ export function Compose({ onPost, onPosted, replyingTo, onCancelReply, placehold
|
||||
}
|
||||
}, [content, lastDetectedUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeMention) {
|
||||
setMentionSuggestions([]);
|
||||
setMentionLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeout = window.setTimeout(async () => {
|
||||
setMentionLoading(true);
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/mentions/suggestions?q=${encodeURIComponent(activeMention.query)}&limit=8`,
|
||||
{ signal: controller.signal, cache: 'no-store' },
|
||||
);
|
||||
if (!response.ok) {
|
||||
setMentionSuggestions([]);
|
||||
return;
|
||||
}
|
||||
const data = await response.json();
|
||||
const suggestions = Array.isArray(data.suggestions) ? data.suggestions : [];
|
||||
setMentionSuggestions(suggestions);
|
||||
setSelectedMentionIndex(0);
|
||||
} catch (error) {
|
||||
if (!(error instanceof DOMException && error.name === 'AbortError')) {
|
||||
console.warn('[Compose] Mention suggestions failed:', error);
|
||||
}
|
||||
} finally {
|
||||
if (!controller.signal.aborted) setMentionLoading(false);
|
||||
}
|
||||
}, 160);
|
||||
|
||||
return () => {
|
||||
window.clearTimeout(timeout);
|
||||
controller.abort();
|
||||
};
|
||||
}, [activeMention]);
|
||||
|
||||
const syncActiveMention = (value: string, caret: number | null) => {
|
||||
setActiveMention(getActiveMentionQuery(value, caret ?? value.length));
|
||||
};
|
||||
|
||||
const chooseMention = (suggestion: MentionSuggestion) => {
|
||||
if (!activeMention) return;
|
||||
const replacement = replaceMentionQuery(content, activeMention, suggestion.handle);
|
||||
setContent(replacement.content);
|
||||
setActiveMention(null);
|
||||
setMentionSuggestions([]);
|
||||
requestAnimationFrame(() => {
|
||||
textareaRef.current?.focus();
|
||||
textareaRef.current?.setSelectionRange(replacement.caret, replacement.caret);
|
||||
});
|
||||
};
|
||||
|
||||
const handleMentionKeyDown = (event: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (!activeMention || mentionSuggestions.length === 0) {
|
||||
if (event.key === 'Escape') setActiveMention(null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === 'ArrowDown') {
|
||||
event.preventDefault();
|
||||
setSelectedMentionIndex((index) => (index + 1) % mentionSuggestions.length);
|
||||
} else if (event.key === 'ArrowUp') {
|
||||
event.preventDefault();
|
||||
setSelectedMentionIndex((index) => (index - 1 + mentionSuggestions.length) % mentionSuggestions.length);
|
||||
} else if (event.key === 'Enter' || event.key === 'Tab') {
|
||||
event.preventDefault();
|
||||
chooseMention(mentionSuggestions[selectedMentionIndex]);
|
||||
} else if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
setActiveMention(null);
|
||||
setMentionSuggestions([]);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchPreview = async (url: string) => {
|
||||
setFetchingPreview(true);
|
||||
try {
|
||||
const res = await fetch(`/api/media/preview?url=${encodeURIComponent(url)}`);
|
||||
if (res.ok) {
|
||||
@@ -104,8 +207,6 @@ export function Compose({ onPost, onPosted, replyingTo, onCancelReply, placehold
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Preview error', err);
|
||||
} finally {
|
||||
setFetchingPreview(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -120,7 +221,7 @@ export function Compose({ onPost, onPosted, replyingTo, onCancelReply, placehold
|
||||
|
||||
setIsPosting(true);
|
||||
try {
|
||||
const posted = await onPost(content, attachments.map((item) => item.id).filter(Boolean), linkPreview, replyingTo?.id, isNsfw);
|
||||
const posted = await onPost(content, attachments.map((item) => item.id).filter(Boolean), linkPreview || undefined, replyingTo?.id, isNsfw);
|
||||
if (posted === false) {
|
||||
setIsPosting(false);
|
||||
return;
|
||||
@@ -236,14 +337,74 @@ export function Compose({ onPost, onPosted, replyingTo, onCancelReply, placehold
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div className="compose-mention-field">
|
||||
<AutoTextarea
|
||||
ref={textareaRef}
|
||||
className="compose-input"
|
||||
placeholder={placeholder}
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
onChange={(event) => {
|
||||
setContent(event.target.value);
|
||||
syncActiveMention(event.target.value, event.target.selectionStart);
|
||||
}}
|
||||
onClick={(event) => syncActiveMention(event.currentTarget.value, event.currentTarget.selectionStart)}
|
||||
onKeyUp={(event) => {
|
||||
if (!['ArrowDown', 'ArrowUp', 'Enter', 'Tab', 'Escape'].includes(event.key)) {
|
||||
syncActiveMention(event.currentTarget.value, event.currentTarget.selectionStart);
|
||||
}
|
||||
}}
|
||||
onKeyDown={handleMentionKeyDown}
|
||||
onBlur={() => window.setTimeout(() => setActiveMention(null), 120)}
|
||||
maxLength={maxLength + 50} // Allow some overflow for better UX
|
||||
autoFocus={autoFocus}
|
||||
role="combobox"
|
||||
aria-autocomplete="list"
|
||||
aria-expanded={Boolean(activeMention && (mentionLoading || mentionSuggestions.length > 0))}
|
||||
aria-controls={mentionListId}
|
||||
aria-activedescendant={mentionSuggestions[selectedMentionIndex]
|
||||
? `${mentionListId}-option-${selectedMentionIndex}`
|
||||
: undefined}
|
||||
/>
|
||||
{activeMention && (mentionLoading || mentionSuggestions.length > 0) && (
|
||||
<div
|
||||
id={mentionListId}
|
||||
className="compose-mention-suggestions"
|
||||
role="listbox"
|
||||
aria-label="Mention suggestions"
|
||||
>
|
||||
{mentionLoading && mentionSuggestions.length === 0 ? (
|
||||
<div className="compose-mention-loading">Finding people…</div>
|
||||
) : mentionSuggestions.map((suggestion, index) => (
|
||||
<button
|
||||
id={`${mentionListId}-option-${index}`}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={index === selectedMentionIndex}
|
||||
className={`compose-mention-suggestion ${index === selectedMentionIndex ? 'selected' : ''}`}
|
||||
key={suggestion.handle}
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
onMouseEnter={() => setSelectedMentionIndex(index)}
|
||||
onClick={() => chooseMention(suggestion)}
|
||||
>
|
||||
<span className="compose-mention-avatar">
|
||||
<AvatarImage
|
||||
avatarUrl={suggestion.avatarUrl}
|
||||
seed={suggestion.handle}
|
||||
alt=""
|
||||
/>
|
||||
</span>
|
||||
<span className="compose-mention-identity">
|
||||
<span className="compose-mention-name">
|
||||
{suggestion.displayName || suggestion.handle.split('@')[0]}
|
||||
{suggestion.isBot && <span className="compose-mention-bot">Bot</span>}
|
||||
</span>
|
||||
<span className="compose-mention-handle">@{suggestion.handle}</span>
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{attachments.length > 0 && (
|
||||
<div className="compose-media-grid">
|
||||
{attachments.map((item) => {
|
||||
|
||||
+25
-15
@@ -11,13 +11,14 @@ import { useToast } from '@/lib/contexts/ToastContext';
|
||||
import { VideoEmbed } from '@/components/VideoEmbed';
|
||||
import BlurredImage from '@/components/BlurredImage';
|
||||
import BlurredVideo from '@/components/BlurredVideo';
|
||||
import { useFormattedHandle } from '@/lib/utils/handle';
|
||||
import { getProfilePath, useFormattedHandle } from '@/lib/utils/handle';
|
||||
import { useDomain } from '@/lib/contexts/ConfigContext';
|
||||
import { signedAPI } from '@/lib/api/signed-fetch';
|
||||
import type { LinkPreviewData } from '@/lib/media/linkPreview';
|
||||
import { AvatarImage } from '@/components/AvatarImage';
|
||||
import { AudioPlayer } from '@/components/AudioPlayer';
|
||||
import { getMediaKind } from '@/lib/media/upload-policy';
|
||||
import { tokenizePostContent } from '@/lib/mentions/parser';
|
||||
|
||||
// Component for link preview image that hides on error
|
||||
function LinkPreviewImage({ src, alt }: { src: string; alt: string }) {
|
||||
@@ -584,9 +585,24 @@ export function PostCard({ post, onLike, onRepost, onComment, onDelete, onHide,
|
||||
|
||||
const renderContent = (content: string, hidePreviewUrl?: string) => {
|
||||
const decoded = decodeHtmlEntities(content);
|
||||
const parts = decoded.split(/(https?:\/\/[^\s]+)/g);
|
||||
return parts.map((part, index) => {
|
||||
if (part.match(/^https?:\/\/[^\s]+$/)) {
|
||||
const tokens = tokenizePostContent(decoded, domain);
|
||||
return tokens.map((token, index) => {
|
||||
if (token.type === 'mention') {
|
||||
return (
|
||||
<Link
|
||||
key={`mention-${token.start}-${token.end}`}
|
||||
href={getProfilePath(token.canonicalHandle)}
|
||||
className="mention-link"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
title={token.isQualified ? token.raw : `@${token.handle}@${domain}`}
|
||||
>
|
||||
{token.raw}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
if (token.type === 'url') {
|
||||
const part = token.value;
|
||||
// If this URL matches the link preview URL, hide it entirely
|
||||
if (hidePreviewUrl && part.includes(hidePreviewUrl.replace(/^https?:\/\/(www\.)?/, '').split('/')[0])) {
|
||||
return null;
|
||||
@@ -622,16 +638,7 @@ export function PostCard({ post, onLike, onRepost, onComment, onDelete, onHide,
|
||||
);
|
||||
}
|
||||
}
|
||||
// Handle newlines
|
||||
if (part.includes('\n')) {
|
||||
return part.split('\n').map((line, lineIndex, arr) => (
|
||||
<span key={`text-${index}-${lineIndex}`}>
|
||||
{line}
|
||||
{lineIndex < arr.length - 1 && <br />}
|
||||
</span>
|
||||
));
|
||||
}
|
||||
return <span key={`text-${index}`}>{part}</span>;
|
||||
return <span key={`text-${token.start}-${index}`}>{token.value}</span>;
|
||||
});
|
||||
};
|
||||
|
||||
@@ -651,7 +658,10 @@ export function PostCard({ post, onLike, onRepost, onComment, onDelete, onHide,
|
||||
? JSON.parse(post.swarmReplyToAuthor)
|
||||
: post.swarmReplyToAuthor)?.nodeDomain,
|
||||
} as Post : null);
|
||||
const replyToHandle = effectiveReplyTo?.author?.handle ? useFormattedHandle(effectiveReplyTo.author.handle, effectiveReplyTo.nodeDomain) : '';
|
||||
const replyToHandle = useFormattedHandle(
|
||||
effectiveReplyTo?.author?.handle || '',
|
||||
effectiveReplyTo?.nodeDomain,
|
||||
);
|
||||
const repostHandle = useFormattedHandle(post.author.handle, post.nodeDomain);
|
||||
const hasOwnContent = decodeHtmlEntities(post.content).trim().length > 0;
|
||||
const isRepostEvent = Boolean(post.repostOf);
|
||||
|
||||
@@ -56,6 +56,7 @@ export const relations = defineRelations(schema, (r) => ({
|
||||
}),
|
||||
likes: r.many.likes({ from: r.posts.id, to: r.likes.postId }),
|
||||
media: r.many.media({ from: r.posts.id, to: r.media.postId }),
|
||||
mentionDeliveries: r.many.mentionDeliveries({ from: r.posts.id, to: r.mentionDeliveries.postId }),
|
||||
},
|
||||
media: {
|
||||
user: r.one.users({ from: r.media.userId, to: r.users.id, optional: false }),
|
||||
@@ -101,6 +102,13 @@ export const relations = defineRelations(schema, (r) => ({
|
||||
}),
|
||||
post: r.one.posts({ from: r.notifications.postId, to: r.posts.id }),
|
||||
},
|
||||
mentionDeliveries: {
|
||||
post: r.one.posts({
|
||||
from: r.mentionDeliveries.postId,
|
||||
to: r.posts.id,
|
||||
optional: false,
|
||||
}),
|
||||
},
|
||||
sessions: {
|
||||
user: r.one.users({ from: r.sessions.userId, to: r.users.id, optional: false }),
|
||||
},
|
||||
|
||||
@@ -409,13 +409,41 @@ export const notifications = sqliteTable('notifications', {
|
||||
targetIsBot: integer('target_is_bot', { mode: 'boolean' }),
|
||||
// Post reference
|
||||
postId: text('post_id').references(() => posts.id, { onDelete: 'cascade' }),
|
||||
remotePostId: text('remote_post_id'),
|
||||
remotePostDomain: text('remote_post_domain'),
|
||||
postContent: text('post_content'), // Cached content for display
|
||||
interactionId: text('interaction_id'), // Idempotency key for local and federated interactions
|
||||
type: text('type').notNull(), // follow | like | repost | mention
|
||||
readAt: integer('read_at', { mode: 'timestamp' }),
|
||||
createdAt: integer('created_at', { mode: 'timestamp' }).default(currentTimestamp).notNull(),
|
||||
}, (table) => [
|
||||
index('notifications_user_idx').on(table.userId),
|
||||
index('notifications_created_idx').on(table.createdAt),
|
||||
uniqueIndex('notifications_interaction_unique_idx').on(table.interactionId),
|
||||
]);
|
||||
|
||||
|
||||
// ============================================
|
||||
// MENTION DELIVERY OUTBOX
|
||||
// ============================================
|
||||
|
||||
export const mentionDeliveries = sqliteTable('mention_deliveries', {
|
||||
id: text('id').primaryKey().$defaultFn(() => randomUUID()),
|
||||
interactionId: text('interaction_id').notNull().unique(),
|
||||
postId: text('post_id').notNull().references(() => posts.id, { onDelete: 'cascade' }),
|
||||
targetHandle: text('target_handle').notNull(),
|
||||
targetDomain: text('target_domain').notNull(),
|
||||
status: text('status').default('pending').notNull(), // pending | processing | retry | delivered | dead
|
||||
attempts: integer('attempts').default(0).notNull(),
|
||||
nextAttemptAt: integer('next_attempt_at', { mode: 'timestamp' }).default(currentTimestamp).notNull(),
|
||||
lastAttemptAt: integer('last_attempt_at', { mode: 'timestamp' }),
|
||||
deliveredAt: integer('delivered_at', { mode: 'timestamp' }),
|
||||
lastError: text('last_error'),
|
||||
createdAt: integer('created_at', { mode: 'timestamp' }).default(currentTimestamp).notNull(),
|
||||
updatedAt: integer('updated_at', { mode: 'timestamp' }).default(currentTimestamp).notNull(),
|
||||
}, (table) => [
|
||||
uniqueIndex('mention_deliveries_target_unique_idx').on(table.postId, table.targetHandle, table.targetDomain),
|
||||
index('mention_deliveries_due_idx').on(table.status, table.nextAttemptAt),
|
||||
]);
|
||||
|
||||
|
||||
@@ -633,6 +661,7 @@ export const botMentions = sqliteTable('bot_mentions', {
|
||||
index('bot_mentions_bot_idx').on(table.botId),
|
||||
index('bot_mentions_processed_idx').on(table.isProcessed),
|
||||
index('bot_mentions_created_idx').on(table.createdAt),
|
||||
uniqueIndex('bot_mentions_bot_post_unique_idx').on(table.botId, table.postId),
|
||||
]);
|
||||
|
||||
|
||||
|
||||
@@ -14,10 +14,13 @@ import { announceToSeeds } from '@/lib/swarm/discovery';
|
||||
import { getSwarmStats } from '@/lib/swarm/registry';
|
||||
import { syncRemoteFollowsPosts } from '@/lib/background/remote-sync';
|
||||
import { isPublicSwarmDomain } from '@/lib/swarm/node-domain';
|
||||
import { processMentionDeliveryOutbox } from '@/lib/mentions/delivery';
|
||||
import { processAllActiveBotMentions } from '@/lib/bots/mentionHandler';
|
||||
|
||||
const BOT_INTERVAL_MS = 60 * 1000; // 1 minute
|
||||
const GOSSIP_INTERVAL_MS = 5 * 60 * 1000; // 5 minutes
|
||||
const REMOTE_SYNC_INTERVAL_MS = 60 * 1000; // 1 minute - keep feeds fresh
|
||||
const MENTION_DELIVERY_INTERVAL_MS = 30 * 1000;
|
||||
const STARTUP_DELAY_MS = 10 * 1000; // Wait 10s for server to be ready
|
||||
|
||||
let isStarted = false;
|
||||
@@ -33,6 +36,7 @@ function log(category: string, message: string, data?: unknown) {
|
||||
|
||||
async function runBotTasks() {
|
||||
try {
|
||||
const mentions = await processAllActiveBotMentions();
|
||||
const results = await processAllAutonomousBots();
|
||||
|
||||
const posted = results.filter(r => r.result.posted).length;
|
||||
@@ -58,11 +62,25 @@ async function runBotTasks() {
|
||||
const errorMsgs = results.filter(r => r.error).map(r => `${r.botHandle}: ${r.error}`);
|
||||
log('BOTS', `Errors: ${errorMsgs.join('; ')}`);
|
||||
}
|
||||
if (mentions.detected > 0 || mentions.responded > 0 || mentions.failed > 0) {
|
||||
log('BOTS', `Mentions: ${mentions.detected} detected, ${mentions.responded} responded, ${mentions.failed} failed`);
|
||||
}
|
||||
} catch (error) {
|
||||
log('BOTS', `Error: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function runMentionDeliveries() {
|
||||
try {
|
||||
const result = await processMentionDeliveryOutbox();
|
||||
if (result.delivered > 0 || result.retried > 0 || result.dead > 0) {
|
||||
log('MENTIONS', `Delivered ${result.delivered}, retrying ${result.retried}, dead-lettered ${result.dead}`);
|
||||
}
|
||||
} catch (error) {
|
||||
log('MENTIONS', `Outbox error: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function runSwarmGossip() {
|
||||
try {
|
||||
const stats = await getSwarmStats();
|
||||
@@ -140,12 +158,14 @@ export function startBackgroundTasks(origin?: string) {
|
||||
|
||||
// Run initial bot check
|
||||
await runBotTasks();
|
||||
await runMentionDeliveries();
|
||||
|
||||
// Run initial remote sync (after 15s to let server stabilize)
|
||||
setTimeout(() => runRemoteSync(syncOrigin), 15 * 1000);
|
||||
|
||||
// Schedule recurring tasks
|
||||
setInterval(runBotTasks, BOT_INTERVAL_MS);
|
||||
setInterval(runMentionDeliveries, MENTION_DELIVERY_INTERVAL_MS);
|
||||
if (publicSwarmEnabled) {
|
||||
setInterval(runSwarmGossip, GOSSIP_INTERVAL_MS);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const mocks = vi.hoisted(() => {
|
||||
const mention = {
|
||||
id: '550e8400-e29b-41d4-a716-446655440000',
|
||||
botId: 'bot-id',
|
||||
postId: 'source-post-id',
|
||||
authorId: 'author-id',
|
||||
content: 'Hello @helperbot',
|
||||
isProcessed: false,
|
||||
processedAt: null as Date | null,
|
||||
responsePostId: null as string | null,
|
||||
isRemote: false,
|
||||
remoteActorUrl: null,
|
||||
createdAt: new Date('2026-07-15T20:00:00.000Z'),
|
||||
};
|
||||
return {
|
||||
mention,
|
||||
generateReply: vi.fn(),
|
||||
registerPostMentions: vi.fn(),
|
||||
recordReply: vi.fn(),
|
||||
deliverPost: vi.fn(),
|
||||
transaction: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
const tables = vi.hoisted(() => ({
|
||||
botMentions: { id: 'botMentions.id', isProcessed: 'botMentions.isProcessed', responsePostId: 'botMentions.responsePostId', processedAt: 'botMentions.processedAt' },
|
||||
notifications: { id: 'notifications.id' },
|
||||
posts: { id: 'posts.id', repliesCount: 'posts.repliesCount' },
|
||||
users: { id: 'users.id', postsCount: 'users.postsCount' },
|
||||
}));
|
||||
|
||||
vi.mock('drizzle-orm', () => ({
|
||||
and: (...values: unknown[]) => values,
|
||||
eq: (...values: unknown[]) => values,
|
||||
isNull: (value: unknown) => value,
|
||||
lte: (...values: unknown[]) => values,
|
||||
or: (...values: unknown[]) => values,
|
||||
sql: (strings: TemplateStringsArray, ...values: unknown[]) => ({ strings, values }),
|
||||
}));
|
||||
|
||||
vi.mock('@/db', () => {
|
||||
const sourcePost = {
|
||||
id: 'source-post-id',
|
||||
userId: 'author-id',
|
||||
content: 'Hello @helperbot',
|
||||
replyToId: null,
|
||||
createdAt: new Date('2026-07-15T20:00:00.000Z'),
|
||||
author: { handle: 'author', displayName: 'Author' },
|
||||
};
|
||||
const responsePost = {
|
||||
id: mocks.mention.id,
|
||||
userId: 'bot-user-id',
|
||||
content: 'A useful response',
|
||||
replyToId: sourcePost.id,
|
||||
createdAt: new Date('2026-07-15T20:01:00.000Z'),
|
||||
isNsfw: false,
|
||||
};
|
||||
const transactionClient = {
|
||||
insert: vi.fn((table: unknown) => ({
|
||||
values: vi.fn(() => table === tables.posts
|
||||
? {
|
||||
onConflictDoNothing: () => ({ returning: vi.fn().mockResolvedValue([responsePost]) }),
|
||||
}
|
||||
: {
|
||||
onConflictDoNothing: vi.fn().mockResolvedValue([]),
|
||||
}),
|
||||
})),
|
||||
update: vi.fn((table: unknown) => ({
|
||||
set: vi.fn((values: Record<string, unknown>) => ({
|
||||
where: vi.fn(async () => {
|
||||
if (table === tables.botMentions && typeof values.responsePostId === 'string') {
|
||||
mocks.mention.isProcessed = true;
|
||||
mocks.mention.processedAt = values.processedAt as Date;
|
||||
mocks.mention.responsePostId = values.responsePostId;
|
||||
}
|
||||
}),
|
||||
})),
|
||||
})),
|
||||
select: vi.fn(() => ({
|
||||
from: () => ({ where: () => ({ limit: vi.fn().mockResolvedValue([]) }) }),
|
||||
})),
|
||||
};
|
||||
const db = {
|
||||
query: {
|
||||
botMentions: {
|
||||
findFirst: vi.fn(async () => ({ ...mocks.mention })),
|
||||
},
|
||||
bots: {
|
||||
findFirst: vi.fn(async () => ({
|
||||
id: 'bot-id',
|
||||
name: 'Helper',
|
||||
personalityConfig: JSON.stringify({ systemPrompt: 'Be useful', temperature: 0.5, maxTokens: 200 }),
|
||||
llmProvider: 'openai',
|
||||
llmModel: 'gpt-4o-mini',
|
||||
llmEndpoint: null,
|
||||
llmApiKeyEncrypted: 'encrypted',
|
||||
user: {
|
||||
id: 'bot-user-id',
|
||||
handle: 'helperbot',
|
||||
displayName: 'Helper Bot',
|
||||
avatarUrl: null,
|
||||
isNsfw: false,
|
||||
did: 'did:synapsis:helperbot',
|
||||
publicKey: 'public-key',
|
||||
},
|
||||
})),
|
||||
},
|
||||
posts: {
|
||||
findFirst: vi.fn(async () => sourcePost),
|
||||
},
|
||||
},
|
||||
update: vi.fn(() => ({
|
||||
set: vi.fn((values: Record<string, unknown>) => ({
|
||||
where: vi.fn(() => {
|
||||
let claimed = false;
|
||||
if (values.isProcessed === true && mocks.mention.isProcessed === false) {
|
||||
claimed = true;
|
||||
mocks.mention.isProcessed = true;
|
||||
mocks.mention.processedAt = values.processedAt as Date;
|
||||
} else if (values.isProcessed === false) {
|
||||
mocks.mention.isProcessed = false;
|
||||
mocks.mention.processedAt = null;
|
||||
}
|
||||
return {
|
||||
returning: vi.fn().mockResolvedValue(claimed ? [{ id: mocks.mention.id }] : []),
|
||||
then: (resolve: (value: unknown) => unknown) => Promise.resolve(undefined).then(resolve),
|
||||
catch: (reject: (reason: unknown) => unknown) => Promise.resolve(undefined).catch(reject),
|
||||
};
|
||||
}),
|
||||
})),
|
||||
})),
|
||||
transaction: mocks.transaction.mockImplementation(async (callback: (tx: typeof transactionClient) => Promise<unknown>) => callback(transactionClient)),
|
||||
};
|
||||
return { db, ...tables };
|
||||
});
|
||||
|
||||
vi.mock('./contentGenerator', () => ({
|
||||
ContentGenerator: class ContentGenerator {
|
||||
generateReply = mocks.generateReply;
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('./rateLimiter', () => ({
|
||||
canReply: vi.fn().mockResolvedValue({ allowed: true }),
|
||||
recordReply: mocks.recordReply,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/mentions/delivery', () => ({
|
||||
registerPostMentions: mocks.registerPostMentions,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/swarm/interactions', () => ({
|
||||
deliverPostToSwarmFollowers: mocks.deliverPost,
|
||||
}));
|
||||
|
||||
import { processMention } from './mentionHandler';
|
||||
|
||||
describe('bot mention processing', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.mention.isProcessed = false;
|
||||
mocks.mention.processedAt = null;
|
||||
mocks.mention.responsePostId = null;
|
||||
mocks.generateReply.mockResolvedValue({ text: 'A useful response', tokensUsed: 12, model: 'test' });
|
||||
mocks.registerPostMentions.mockResolvedValue({ localNotifications: 0, remoteQueued: 0, skipped: 0 });
|
||||
mocks.recordReply.mockResolvedValue(undefined);
|
||||
mocks.deliverPost.mockResolvedValue({ delivered: 0, failed: 0 });
|
||||
});
|
||||
|
||||
it('persists one deterministic reply and returns it on a repeated request', async () => {
|
||||
const first = await processMention(mocks.mention.id);
|
||||
const second = await processMention(mocks.mention.id);
|
||||
|
||||
expect(first).toEqual({ success: true, responsePostId: mocks.mention.id });
|
||||
expect(second).toEqual(first);
|
||||
expect(mocks.transaction).toHaveBeenCalledOnce();
|
||||
expect(mocks.generateReply).toHaveBeenCalledOnce();
|
||||
expect(mocks.registerPostMentions).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('rejects a concurrent processor while the first owns the lease', async () => {
|
||||
let finishGeneration!: (value: { text: string; tokensUsed: number; model: string }) => void;
|
||||
mocks.generateReply.mockReturnValue(new Promise((resolve) => {
|
||||
finishGeneration = resolve;
|
||||
}));
|
||||
|
||||
const firstRequest = processMention(mocks.mention.id);
|
||||
await vi.waitFor(() => expect(mocks.generateReply).toHaveBeenCalledOnce());
|
||||
|
||||
await expect(processMention(mocks.mention.id)).resolves.toEqual({
|
||||
success: false,
|
||||
error: 'Mention is already being processed',
|
||||
});
|
||||
|
||||
finishGeneration({ text: 'A useful response', tokensUsed: 12, model: 'test' });
|
||||
await expect(firstRequest).resolves.toEqual({
|
||||
success: true,
|
||||
responsePostId: mocks.mention.id,
|
||||
});
|
||||
expect(mocks.transaction).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
@@ -226,9 +226,13 @@ vi.mock('@/db', () => {
|
||||
responsePostId: null,
|
||||
};
|
||||
mentionsStore.set(id, mention);
|
||||
return {
|
||||
const result = {
|
||||
returning: vi.fn().mockResolvedValue([mention]),
|
||||
};
|
||||
return {
|
||||
...result,
|
||||
onConflictDoNothing: vi.fn().mockReturnValue(result),
|
||||
};
|
||||
} else {
|
||||
// This is a post insert
|
||||
const id = `post-${++postIdCounter}`;
|
||||
|
||||
+206
-27
@@ -7,11 +7,16 @@
|
||||
* Requirements: 7.1, 7.2, 7.3, 7.4, 7.5, 7.6
|
||||
*/
|
||||
|
||||
import { db, bots, botMentions, posts, users } from '@/db';
|
||||
import { eq, and, desc, asc, isNull } from 'drizzle-orm';
|
||||
import { db, botMentions, notifications, posts, users } from '@/db';
|
||||
import { and, eq, isNull, lte, or, sql } from 'drizzle-orm';
|
||||
import { ContentGenerator, type Bot as GeneratorBot, type Post as GeneratorPost } from './contentGenerator';
|
||||
import { canReply, recordReply } from './rateLimiter';
|
||||
import { decryptApiKey } from './encryption';
|
||||
import type { LLMProvider } from './encryption';
|
||||
import { parseMentions, uniqueMentions } from '@/lib/mentions/parser';
|
||||
import { registerPostMentions } from '@/lib/mentions/delivery';
|
||||
import { deliverPostToSwarmFollowers } from '@/lib/swarm/interactions';
|
||||
|
||||
const MENTION_PROCESSING_LEASE_MS = 10 * 60 * 1000;
|
||||
|
||||
// ============================================
|
||||
// TYPES
|
||||
@@ -50,6 +55,18 @@ export interface PostWithAuthor {
|
||||
};
|
||||
}
|
||||
|
||||
interface ConversationPostRow {
|
||||
id: string;
|
||||
userId: string;
|
||||
content: string;
|
||||
replyToId: string | null;
|
||||
createdAt: Date;
|
||||
author: {
|
||||
handle: string;
|
||||
displayName: string | null;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Mention detection result.
|
||||
*/
|
||||
@@ -109,7 +126,7 @@ export async function detectMentions(botId: string): Promise<MentionDetectionRes
|
||||
where: { id: botId },
|
||||
with: {
|
||||
user: {
|
||||
columns: { handle: true },
|
||||
columns: { id: true, handle: true },
|
||||
},
|
||||
},
|
||||
columns: { id: true },
|
||||
@@ -130,15 +147,8 @@ export async function detectMentions(botId: string): Promise<MentionDetectionRes
|
||||
|
||||
const existingPostIds = new Set(existingMentions.map(m => m.postId));
|
||||
|
||||
// Find posts that mention the bot's handle
|
||||
// Note: In a production system, this would be more efficient with full-text search
|
||||
// or a dedicated mentions table updated on post creation
|
||||
const mentionPattern = `@${bot.user.handle}`;
|
||||
|
||||
// Get recent posts (last 24 hours) that might contain mentions
|
||||
const oneDayAgo = new Date();
|
||||
oneDayAgo.setDate(oneDayAgo.getDate() - 1);
|
||||
|
||||
// Direct post creation records bot mentions immediately. This bounded scan
|
||||
// remains as a repair path for imported posts and older nodes.
|
||||
const recentPosts = await db.query.posts.findMany({
|
||||
where: { AND: [{ isRemoved: false }] },
|
||||
with: {
|
||||
@@ -155,9 +165,12 @@ export async function detectMentions(botId: string): Promise<MentionDetectionRes
|
||||
});
|
||||
|
||||
// Filter posts that mention the bot and aren't already tracked
|
||||
const newMentionPosts = recentPosts.filter(post =>
|
||||
post.content.includes(mentionPattern) &&
|
||||
!existingPostIds.has(post.id)
|
||||
const newMentionPosts = recentPosts.filter((post) =>
|
||||
post.userId !== bot.user.id
|
||||
&& !existingPostIds.has(post.id)
|
||||
&& uniqueMentions(parseMentions(post.content)).some(
|
||||
(mention) => mention.isLocal && mention.handle === bot.user.handle.toLowerCase(),
|
||||
)
|
||||
);
|
||||
|
||||
// Create mention records
|
||||
@@ -171,7 +184,9 @@ export async function detectMentions(botId: string): Promise<MentionDetectionRes
|
||||
content: post.content,
|
||||
isProcessed: false,
|
||||
isRemote: false, // Local mentions for now
|
||||
}).returning();
|
||||
}).onConflictDoNothing().returning();
|
||||
|
||||
if (!mention) continue;
|
||||
|
||||
newMentions.push({
|
||||
id: mention.id,
|
||||
@@ -301,7 +316,7 @@ export async function getConversationContext(
|
||||
let depth = 0;
|
||||
|
||||
while (currentPostId && depth < maxDepth) {
|
||||
const post: any = await db.query.posts.findFirst({
|
||||
const post = await db.query.posts.findFirst({
|
||||
where: { id: currentPostId },
|
||||
with: {
|
||||
author: {
|
||||
@@ -311,7 +326,7 @@ export async function getConversationContext(
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}) as ConversationPostRow | undefined;
|
||||
|
||||
if (!post) break;
|
||||
|
||||
@@ -357,6 +372,8 @@ export async function getConversationContext(
|
||||
* Validates: Requirements 7.3, 7.4, 7.6
|
||||
*/
|
||||
export async function processMention(mentionId: string): Promise<MentionResponseResult> {
|
||||
let ownsLease = false;
|
||||
let completed = false;
|
||||
try {
|
||||
// Get the mention
|
||||
const mention = await db.query.botMentions.findFirst({
|
||||
@@ -370,13 +387,45 @@ export async function processMention(mentionId: string): Promise<MentionResponse
|
||||
);
|
||||
}
|
||||
|
||||
// Check if already processed
|
||||
// A completed response is idempotent. A stale claim is released so a
|
||||
// process crash cannot strand a mention forever.
|
||||
if (mention.isProcessed) {
|
||||
if (!mention.responsePostId) {
|
||||
const staleBefore = new Date(Date.now() - MENTION_PROCESSING_LEASE_MS);
|
||||
const isStale = !mention.processedAt || mention.processedAt <= staleBefore;
|
||||
if (!isStale) {
|
||||
return { success: false, error: 'Mention is already being processed' };
|
||||
}
|
||||
await db.update(botMentions).set({
|
||||
isProcessed: false,
|
||||
processedAt: null,
|
||||
}).where(and(
|
||||
eq(botMentions.id, mentionId),
|
||||
eq(botMentions.isProcessed, true),
|
||||
isNull(botMentions.responsePostId),
|
||||
));
|
||||
} else {
|
||||
return {
|
||||
success: true,
|
||||
responsePostId: mention.responsePostId || undefined,
|
||||
responsePostId: mention.responsePostId,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const claimed = await db.update(botMentions).set({
|
||||
isProcessed: true,
|
||||
processedAt: new Date(),
|
||||
}).where(and(
|
||||
eq(botMentions.id, mentionId),
|
||||
eq(botMentions.isProcessed, false),
|
||||
)).returning({ id: botMentions.id });
|
||||
if (claimed.length === 0) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Mention is already being processed',
|
||||
};
|
||||
}
|
||||
ownsLease = true;
|
||||
|
||||
// Check rate limits (Requirement 7.6)
|
||||
const rateLimitCheck = await canReply(mention.botId);
|
||||
@@ -395,6 +444,11 @@ export async function processMention(mentionId: string): Promise<MentionResponse
|
||||
columns: {
|
||||
id: true,
|
||||
handle: true,
|
||||
displayName: true,
|
||||
avatarUrl: true,
|
||||
isNsfw: true,
|
||||
did: true,
|
||||
publicKey: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -439,7 +493,7 @@ export async function processMention(mentionId: string): Promise<MentionResponse
|
||||
name: bot.name,
|
||||
handle: bot.user.handle,
|
||||
personalityConfig: JSON.parse(bot.personalityConfig),
|
||||
llmProvider: bot.llmProvider as any,
|
||||
llmProvider: bot.llmProvider as LLMProvider,
|
||||
llmModel: bot.llmModel,
|
||||
llmEndpoint: bot.llmEndpoint,
|
||||
llmApiKeyEncrypted: bot.llmApiKeyEncrypted,
|
||||
@@ -465,29 +519,118 @@ export async function processMention(mentionId: string): Promise<MentionResponse
|
||||
);
|
||||
|
||||
// Create response post
|
||||
const [responsePost] = await db.insert(posts).values({
|
||||
const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:43821';
|
||||
const postUuid = mention.id;
|
||||
const responsePost = await db.transaction(async (tx) => {
|
||||
const [createdPost] = await tx.insert(posts).values({
|
||||
id: postUuid,
|
||||
userId: bot.user.id, // Bot posts as its associated user
|
||||
content: generatedReply.text,
|
||||
replyToId: mention.postId,
|
||||
}).returning();
|
||||
isNsfw: bot.user.isNsfw,
|
||||
apId: `https://${nodeDomain}/posts/${postUuid}`,
|
||||
apUrl: `https://${nodeDomain}/posts/${postUuid}`,
|
||||
}).onConflictDoNothing().returning();
|
||||
const [existingPost] = createdPost ? [] : await tx.select().from(posts)
|
||||
.where(eq(posts.id, postUuid))
|
||||
.limit(1);
|
||||
const reply = createdPost || existingPost;
|
||||
if (!reply || reply.userId !== bot.user.id || reply.replyToId !== mention.postId) {
|
||||
throw new MentionHandlerError('Unable to persist bot reply', 'DATABASE_ERROR');
|
||||
}
|
||||
|
||||
// Mark mention as processed
|
||||
await db.update(botMentions)
|
||||
if (!createdPost) {
|
||||
await tx.update(botMentions).set({
|
||||
isProcessed: true,
|
||||
processedAt: new Date(),
|
||||
responsePostId: reply.id,
|
||||
}).where(eq(botMentions.id, mentionId));
|
||||
return reply;
|
||||
}
|
||||
|
||||
await tx.update(users)
|
||||
.set({ postsCount: sql`${users.postsCount} + 1` })
|
||||
.where(eq(users.id, bot.user.id));
|
||||
await tx.update(posts)
|
||||
.set({ repliesCount: sql`${posts.repliesCount} + 1` })
|
||||
.where(eq(posts.id, mention.postId));
|
||||
await tx.insert(notifications).values({
|
||||
userId: mention.authorId,
|
||||
actorId: bot.user.id,
|
||||
actorHandle: bot.user.handle,
|
||||
actorDisplayName: bot.user.displayName,
|
||||
actorAvatarUrl: bot.user.avatarUrl,
|
||||
actorNodeDomain: null,
|
||||
postId: reply.id,
|
||||
postContent: reply.content.slice(0, 200),
|
||||
interactionId: `bot-reply:${mention.id}`,
|
||||
type: 'reply',
|
||||
}).onConflictDoNothing();
|
||||
await tx.update(botMentions)
|
||||
.set({
|
||||
isProcessed: true,
|
||||
processedAt: new Date(),
|
||||
responsePostId: responsePost.id,
|
||||
responsePostId: reply.id,
|
||||
})
|
||||
.where(eq(botMentions.id, mentionId));
|
||||
return reply;
|
||||
});
|
||||
completed = true;
|
||||
|
||||
try {
|
||||
await registerPostMentions({
|
||||
postId: responsePost.id,
|
||||
content: responsePost.content,
|
||||
actor: {
|
||||
id: bot.user.id,
|
||||
handle: bot.user.handle,
|
||||
displayName: bot.user.displayName,
|
||||
avatarUrl: bot.user.avatarUrl,
|
||||
did: bot.user.did,
|
||||
publicKey: bot.user.publicKey,
|
||||
},
|
||||
nodeDomain,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[Bots] Failed to register mentions in bot reply:', error);
|
||||
}
|
||||
|
||||
// Record reply for rate limiting
|
||||
try {
|
||||
await recordReply(mention.botId);
|
||||
} catch (error) {
|
||||
console.error('[Bots] Failed to record mention reply rate limit:', error);
|
||||
}
|
||||
|
||||
void deliverPostToSwarmFollowers(
|
||||
bot.user.id,
|
||||
responsePost,
|
||||
{
|
||||
handle: bot.user.handle,
|
||||
displayName: bot.user.displayName,
|
||||
avatarUrl: bot.user.avatarUrl,
|
||||
isNsfw: bot.user.isNsfw,
|
||||
},
|
||||
[],
|
||||
nodeDomain,
|
||||
).catch((error) => console.error('[Bots] Failed to federate mention reply:', error));
|
||||
|
||||
return {
|
||||
success: true,
|
||||
responsePostId: responsePost.id,
|
||||
};
|
||||
} catch (error) {
|
||||
if (ownsLease && !completed) {
|
||||
await db.update(botMentions).set({
|
||||
isProcessed: false,
|
||||
processedAt: null,
|
||||
}).where(and(
|
||||
eq(botMentions.id, mentionId),
|
||||
isNull(botMentions.responsePostId),
|
||||
)).catch((releaseError) => {
|
||||
console.error(`[Bots] Failed to release mention claim ${mentionId}:`, releaseError);
|
||||
});
|
||||
}
|
||||
if (error instanceof MentionHandlerError) {
|
||||
return {
|
||||
success: false,
|
||||
@@ -539,6 +682,42 @@ export async function processAllMentions(botId: string): Promise<MentionResponse
|
||||
return results;
|
||||
}
|
||||
|
||||
/** Detect and process pending mentions for every active bot. */
|
||||
export async function processAllActiveBotMentions(): Promise<{
|
||||
bots: number;
|
||||
detected: number;
|
||||
responded: number;
|
||||
failed: number;
|
||||
}> {
|
||||
const staleBefore = new Date(Date.now() - MENTION_PROCESSING_LEASE_MS);
|
||||
await db.update(botMentions).set({ isProcessed: false, processedAt: null })
|
||||
.where(and(
|
||||
eq(botMentions.isProcessed, true),
|
||||
isNull(botMentions.responsePostId),
|
||||
or(isNull(botMentions.processedAt), lte(botMentions.processedAt, staleBefore)),
|
||||
));
|
||||
|
||||
const activeBots = await db.query.bots.findMany({
|
||||
where: { AND: [{ isActive: true }, { isSuspended: false }] },
|
||||
columns: { id: true },
|
||||
});
|
||||
const totals = { bots: activeBots.length, detected: 0, responded: 0, failed: 0 };
|
||||
|
||||
for (const bot of activeBots) {
|
||||
try {
|
||||
const detected = await detectMentions(bot.id);
|
||||
totals.detected += detected.mentions.length;
|
||||
const responses = await processAllMentions(bot.id);
|
||||
totals.responded += responses.filter((response) => response.success).length;
|
||||
totals.failed += responses.filter((response) => !response.success).length;
|
||||
} catch (error) {
|
||||
totals.failed += 1;
|
||||
console.error(`[Bots] Mention processing failed for ${bot.id}:`, error);
|
||||
}
|
||||
}
|
||||
return totals;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// MENTION STORAGE
|
||||
// ============================================
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { mentionRetryDelayMs } from './delivery';
|
||||
|
||||
describe('mention delivery retry schedule', () => {
|
||||
it('uses bounded exponential backoff', () => {
|
||||
expect(mentionRetryDelayMs(1)).toBe(30_000);
|
||||
expect(mentionRetryDelayMs(2)).toBe(60_000);
|
||||
expect(mentionRetryDelayMs(3)).toBe(120_000);
|
||||
expect(mentionRetryDelayMs(20)).toBe(3_600_000);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,318 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { and, eq, lte, or } from 'drizzle-orm';
|
||||
|
||||
import {
|
||||
botMentions,
|
||||
db,
|
||||
mentionDeliveries,
|
||||
notifications,
|
||||
} from '@/db';
|
||||
import { buildNotificationTarget } from '@/lib/notifications';
|
||||
import { discoverNode } from '@/lib/swarm/discovery';
|
||||
import {
|
||||
deliverSwarmMention,
|
||||
isSwarmNode,
|
||||
type SwarmInteractionResponse,
|
||||
} from '@/lib/swarm/interactions';
|
||||
import { normalizeNodeDomain } from '@/lib/swarm/node-domain';
|
||||
import { parseMentions, uniqueMentions } from './parser';
|
||||
|
||||
const MAX_DELIVERY_ATTEMPTS = 12;
|
||||
const PROCESSING_LEASE_MS = 2 * 60 * 1000;
|
||||
let activeWorker: Promise<MentionOutboxResult> | null = null;
|
||||
|
||||
export interface MentionActor {
|
||||
id: string;
|
||||
handle: string;
|
||||
displayName: string | null;
|
||||
avatarUrl: string | null;
|
||||
did?: string | null;
|
||||
publicKey?: string | null;
|
||||
}
|
||||
|
||||
export interface RegisterPostMentionsInput {
|
||||
postId: string;
|
||||
content: string;
|
||||
actor: MentionActor;
|
||||
nodeDomain?: string;
|
||||
}
|
||||
|
||||
export interface RegisterPostMentionsResult {
|
||||
localNotifications: number;
|
||||
remoteQueued: number;
|
||||
skipped: number;
|
||||
}
|
||||
|
||||
export interface MentionOutboxResult {
|
||||
delivered: number;
|
||||
retried: number;
|
||||
dead: number;
|
||||
}
|
||||
|
||||
export function mentionRetryDelayMs(attempt: number): number {
|
||||
return Math.min(60 * 60 * 1000, 30 * 1000 * (2 ** Math.max(0, attempt - 1)));
|
||||
}
|
||||
|
||||
async function localInteractionAllowed(recipientId: string, actorId: string): Promise<boolean> {
|
||||
const [block, mute] = await Promise.all([
|
||||
db.query.blocks.findFirst({
|
||||
where: {
|
||||
OR: [
|
||||
{ AND: [{ userId: recipientId }, { blockedUserId: actorId }] },
|
||||
{ AND: [{ userId: actorId }, { blockedUserId: recipientId }] },
|
||||
],
|
||||
},
|
||||
columns: { id: true },
|
||||
}),
|
||||
db.query.mutes.findFirst({
|
||||
where: { AND: [{ userId: recipientId }, { mutedUserId: actorId }] },
|
||||
columns: { id: true },
|
||||
}),
|
||||
]);
|
||||
return !block && !mute;
|
||||
}
|
||||
|
||||
async function insertMentionNotification(values: typeof notifications.$inferInsert): Promise<boolean> {
|
||||
const inserted = await db.insert(notifications)
|
||||
.values(values)
|
||||
.onConflictDoNothing()
|
||||
.returning({ id: notifications.id });
|
||||
return inserted.length > 0;
|
||||
}
|
||||
|
||||
async function registerLocalBotMention(
|
||||
mentionedUserId: string,
|
||||
postId: string,
|
||||
actorId: string,
|
||||
content: string,
|
||||
): Promise<void> {
|
||||
const bot = await db.query.bots.findFirst({
|
||||
where: { userId: mentionedUserId },
|
||||
columns: { id: true, isActive: true, isSuspended: true },
|
||||
});
|
||||
if (!bot || !bot.isActive || bot.isSuspended) return;
|
||||
|
||||
await db.insert(botMentions).values({
|
||||
botId: bot.id,
|
||||
postId,
|
||||
authorId: actorId,
|
||||
content,
|
||||
isProcessed: false,
|
||||
isRemote: false,
|
||||
}).onConflictDoNothing();
|
||||
}
|
||||
|
||||
/** Resolve local mentions synchronously and persist remote delivery work. */
|
||||
export async function registerPostMentions(
|
||||
input: RegisterPostMentionsInput,
|
||||
): Promise<RegisterPostMentionsResult> {
|
||||
const nodeDomain = normalizeNodeDomain(
|
||||
input.nodeDomain || process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:43821',
|
||||
);
|
||||
const mentions = uniqueMentions(parseMentions(input.content, nodeDomain));
|
||||
const result: RegisterPostMentionsResult = {
|
||||
localNotifications: 0,
|
||||
remoteQueued: 0,
|
||||
skipped: 0,
|
||||
};
|
||||
|
||||
for (const mention of mentions) {
|
||||
if (mention.isLocal) {
|
||||
const mentionedUser = await db.query.users.findFirst({
|
||||
where: { handle: mention.handle },
|
||||
});
|
||||
if (!mentionedUser
|
||||
|| mentionedUser.id === input.actor.id
|
||||
|| mentionedUser.isSuspended
|
||||
|| !(await localInteractionAllowed(mentionedUser.id, input.actor.id))) {
|
||||
result.skipped += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const created = await insertMentionNotification({
|
||||
userId: mentionedUser.id,
|
||||
actorId: input.actor.id,
|
||||
actorHandle: input.actor.handle,
|
||||
actorDisplayName: input.actor.displayName,
|
||||
actorAvatarUrl: input.actor.avatarUrl,
|
||||
actorNodeDomain: null,
|
||||
postId: input.postId,
|
||||
postContent: input.content.slice(0, 200),
|
||||
interactionId: `mention:local:${input.postId}:${mentionedUser.id}`,
|
||||
...(mentionedUser.isBot ? buildNotificationTarget(mentionedUser) : {}),
|
||||
type: 'mention',
|
||||
});
|
||||
if (created) result.localNotifications += 1;
|
||||
|
||||
if (mentionedUser.isBot) {
|
||||
await registerLocalBotMention(mentionedUser.id, input.postId, input.actor.id, input.content);
|
||||
}
|
||||
|
||||
if (mentionedUser.isBot
|
||||
&& mentionedUser.botOwnerId
|
||||
&& await localInteractionAllowed(mentionedUser.botOwnerId, input.actor.id)) {
|
||||
const ownerCreated = await insertMentionNotification({
|
||||
userId: mentionedUser.botOwnerId,
|
||||
actorId: input.actor.id,
|
||||
actorHandle: input.actor.handle,
|
||||
actorDisplayName: input.actor.displayName,
|
||||
actorAvatarUrl: input.actor.avatarUrl,
|
||||
actorNodeDomain: null,
|
||||
postId: input.postId,
|
||||
postContent: input.content.slice(0, 200),
|
||||
interactionId: `mention:local-owner:${input.postId}:${mentionedUser.id}`,
|
||||
...buildNotificationTarget(mentionedUser),
|
||||
type: 'mention',
|
||||
});
|
||||
if (ownerCreated) result.localNotifications += 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!mention.domain) {
|
||||
result.skipped += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const inserted = await db.insert(mentionDeliveries).values({
|
||||
interactionId: randomUUID(),
|
||||
postId: input.postId,
|
||||
targetHandle: mention.handle,
|
||||
targetDomain: normalizeNodeDomain(mention.domain),
|
||||
status: 'pending',
|
||||
nextAttemptAt: new Date(),
|
||||
}).onConflictDoNothing().returning({ id: mentionDeliveries.id });
|
||||
if (inserted.length > 0) result.remoteQueued += 1;
|
||||
}
|
||||
|
||||
if (result.remoteQueued > 0) {
|
||||
void processMentionDeliveryOutbox().catch((error) => {
|
||||
console.error('[Mentions] Immediate outbox processing failed:', error);
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async function markDeliveryFailure(
|
||||
delivery: typeof mentionDeliveries.$inferSelect,
|
||||
response: SwarmInteractionResponse,
|
||||
): Promise<'retry' | 'dead'> {
|
||||
const attempts = delivery.attempts + 1;
|
||||
const isDead = response.retryable === false || attempts >= MAX_DELIVERY_ATTEMPTS;
|
||||
const now = new Date();
|
||||
await db.update(mentionDeliveries).set({
|
||||
status: isDead ? 'dead' : 'retry',
|
||||
attempts,
|
||||
nextAttemptAt: isDead ? now : new Date(now.getTime() + mentionRetryDelayMs(attempts)),
|
||||
lastError: (response.error || 'Unknown delivery failure').slice(0, 1000),
|
||||
updatedAt: now,
|
||||
}).where(eq(mentionDeliveries.id, delivery.id));
|
||||
return isDead ? 'dead' : 'retry';
|
||||
}
|
||||
|
||||
async function attemptMentionDelivery(
|
||||
delivery: typeof mentionDeliveries.$inferSelect,
|
||||
): Promise<'delivered' | 'retry' | 'dead'> {
|
||||
const post = await db.query.posts.findFirst({
|
||||
where: { id: delivery.postId },
|
||||
with: { author: true },
|
||||
});
|
||||
if (!post || post.isRemoved || post.author.isSuspended) {
|
||||
return markDeliveryFailure(delivery, {
|
||||
success: false,
|
||||
retryable: false,
|
||||
error: 'Source post or actor is unavailable',
|
||||
});
|
||||
}
|
||||
|
||||
let knownNode = await isSwarmNode(delivery.targetDomain);
|
||||
if (!knownNode) knownNode = (await discoverNode(delivery.targetDomain)).success;
|
||||
if (!knownNode) {
|
||||
return markDeliveryFailure(delivery, {
|
||||
success: false,
|
||||
retryable: true,
|
||||
error: `Unable to discover Synapsis node ${delivery.targetDomain}`,
|
||||
});
|
||||
}
|
||||
|
||||
const response = await deliverSwarmMention(delivery.targetDomain, {
|
||||
mentionedHandle: delivery.targetHandle,
|
||||
mention: {
|
||||
actorHandle: post.author.handle,
|
||||
actorDisplayName: post.author.displayName || post.author.handle,
|
||||
actorAvatarUrl: post.author.avatarUrl || undefined,
|
||||
actorNodeDomain: normalizeNodeDomain(process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:43821'),
|
||||
actorDid: post.author.did,
|
||||
actorPublicKey: post.author.publicKey,
|
||||
postId: post.id,
|
||||
postContent: post.content,
|
||||
interactionId: delivery.interactionId,
|
||||
timestamp: post.createdAt.toISOString(),
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.success) return markDeliveryFailure(delivery, response);
|
||||
|
||||
await db.update(mentionDeliveries).set({
|
||||
status: 'delivered',
|
||||
attempts: delivery.attempts + 1,
|
||||
deliveredAt: new Date(),
|
||||
lastError: null,
|
||||
updatedAt: new Date(),
|
||||
}).where(eq(mentionDeliveries.id, delivery.id));
|
||||
return 'delivered';
|
||||
}
|
||||
|
||||
async function runMentionDeliveryOutbox(limit: number): Promise<MentionOutboxResult> {
|
||||
const result: MentionOutboxResult = { delivered: 0, retried: 0, dead: 0 };
|
||||
const now = new Date();
|
||||
const staleBefore = new Date(now.getTime() - PROCESSING_LEASE_MS);
|
||||
|
||||
await db.update(mentionDeliveries).set({ status: 'retry', nextAttemptAt: now, updatedAt: now })
|
||||
.where(and(
|
||||
eq(mentionDeliveries.status, 'processing'),
|
||||
lte(mentionDeliveries.lastAttemptAt, staleBefore),
|
||||
));
|
||||
|
||||
const due = await db.select().from(mentionDeliveries)
|
||||
.where(and(
|
||||
or(eq(mentionDeliveries.status, 'pending'), eq(mentionDeliveries.status, 'retry')),
|
||||
lte(mentionDeliveries.nextAttemptAt, now),
|
||||
))
|
||||
.limit(Math.max(1, Math.min(limit, 100)));
|
||||
|
||||
for (const delivery of due) {
|
||||
const claimed = await db.update(mentionDeliveries).set({
|
||||
status: 'processing',
|
||||
lastAttemptAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
}).where(and(
|
||||
eq(mentionDeliveries.id, delivery.id),
|
||||
or(eq(mentionDeliveries.status, 'pending'), eq(mentionDeliveries.status, 'retry')),
|
||||
)).returning({ id: mentionDeliveries.id });
|
||||
if (claimed.length === 0) continue;
|
||||
|
||||
try {
|
||||
const state = await attemptMentionDelivery(delivery);
|
||||
result[state === 'retry' ? 'retried' : state] += 1;
|
||||
} catch (error) {
|
||||
const state = await markDeliveryFailure(delivery, {
|
||||
success: false,
|
||||
retryable: true,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
result[state === 'dead' ? 'dead' : 'retried'] += 1;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function processMentionDeliveryOutbox(limit = 25): Promise<MentionOutboxResult> {
|
||||
if (activeWorker) return activeWorker;
|
||||
activeWorker = runMentionDeliveryOutbox(limit);
|
||||
try {
|
||||
return await activeWorker;
|
||||
} finally {
|
||||
activeWorker = null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
getActiveMentionQuery,
|
||||
parseMentions,
|
||||
replaceMentionQuery,
|
||||
tokenizePostContent,
|
||||
uniqueMentions,
|
||||
} from './parser';
|
||||
|
||||
describe('mention parser', () => {
|
||||
it('parses local, remote, and same-node qualified handles', () => {
|
||||
const mentions = parseMentions(
|
||||
'Hi @Alice, @bob@remote.example and @carol@local.example.',
|
||||
'local.example',
|
||||
);
|
||||
|
||||
expect(mentions.map(({ canonicalHandle, isLocal, raw }) => ({ canonicalHandle, isLocal, raw }))).toEqual([
|
||||
{ canonicalHandle: 'alice', isLocal: true, raw: '@Alice' },
|
||||
{ canonicalHandle: 'bob@remote.example', isLocal: false, raw: '@bob@remote.example' },
|
||||
{ canonicalHandle: 'carol', isLocal: true, raw: '@carol@local.example' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('rejects email addresses, URL path fragments, malformed domains, and overlong handles', () => {
|
||||
const content = [
|
||||
'mail alice@example.com',
|
||||
'visit https://example.com/@alice',
|
||||
'@alice@bad_domain',
|
||||
'@abcdefghijklmnopqrstuvwxyzabcde',
|
||||
'@alice.example',
|
||||
].join(' ');
|
||||
|
||||
expect(parseMentions(content, 'local.example')).toEqual([]);
|
||||
});
|
||||
|
||||
it('keeps sentence punctuation outside the mention range', () => {
|
||||
const [mention] = parseMentions('Hello (@alice@remote.example).');
|
||||
expect(mention.raw).toBe('@alice@remote.example');
|
||||
expect(mention.end).toBe('Hello (@alice@remote.example'.length);
|
||||
});
|
||||
|
||||
it('supports the full bot handle length', () => {
|
||||
const handle = 'b'.repeat(30);
|
||||
expect(parseMentions(`Ask @${handle}`)[0]).toMatchObject({ handle });
|
||||
});
|
||||
|
||||
it('deduplicates aliases that resolve to the same local account', () => {
|
||||
const mentions = parseMentions('@alice @Alice@local.example @bob@remote.example @bob@remote.example', 'local.example');
|
||||
expect(uniqueMentions(mentions).map((mention) => mention.canonicalHandle)).toEqual([
|
||||
'alice',
|
||||
'bob@remote.example',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('composer mention queries', () => {
|
||||
it('finds local and qualified queries at the caret', () => {
|
||||
expect(getActiveMentionQuery('hello @ali', 10)).toMatchObject({
|
||||
start: 6,
|
||||
query: 'ali',
|
||||
handleQuery: 'ali',
|
||||
domainQuery: null,
|
||||
});
|
||||
expect(getActiveMentionQuery('@alice@remote.ex', 16)).toMatchObject({
|
||||
start: 0,
|
||||
query: 'alice@remote.ex',
|
||||
handleQuery: 'alice',
|
||||
domainQuery: 'remote.ex',
|
||||
});
|
||||
});
|
||||
|
||||
it('does not activate inside an email address or URL', () => {
|
||||
expect(getActiveMentionQuery('alice@example', 13)).toBeNull();
|
||||
expect(getActiveMentionQuery('https://example.com/@ali', 24)).toBeNull();
|
||||
});
|
||||
|
||||
it('replaces only the active query and returns the next caret position', () => {
|
||||
const active = getActiveMentionQuery('Hello @ali friend', 10)!;
|
||||
expect(replaceMentionQuery('Hello @ali friend', active, 'alice')).toEqual({
|
||||
content: 'Hello @alice friend',
|
||||
caret: 12,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('rich text tokenization', () => {
|
||||
it('emits clickable ranges without interpreting mentions inside URLs', () => {
|
||||
const tokens = tokenizePostContent('See https://example.com/@alice and ask @bob.', 'local.example');
|
||||
expect(tokens.filter((token) => token.type !== 'text').map((token) => ({ type: token.type, value: token.value }))).toEqual([
|
||||
{ type: 'url', value: 'https://example.com/@alice' },
|
||||
{ type: 'mention', value: '@bob' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,216 @@
|
||||
import { resolveUserHandle } from '@/lib/swarm/user-handle';
|
||||
import { isValidNodeDomain } from '@/lib/utils/federation';
|
||||
|
||||
const HANDLE_PATTERN = /^[a-zA-Z0-9_]{3,30}$/;
|
||||
const HANDLE_CHARACTER = /[a-zA-Z0-9_]/;
|
||||
const MENTION_PATTERN = /@([a-zA-Z0-9_]{3,30})(?:@([a-zA-Z0-9.-]+(?::\d{1,5})?))?/g;
|
||||
const URL_PATTERN = /https?:\/\/[^\s<]+/gi;
|
||||
|
||||
export interface ParsedMention {
|
||||
raw: string;
|
||||
start: number;
|
||||
end: number;
|
||||
handle: string;
|
||||
domain: string | null;
|
||||
canonicalHandle: string;
|
||||
isQualified: boolean;
|
||||
isLocal: boolean;
|
||||
}
|
||||
|
||||
export interface ActiveMentionQuery {
|
||||
start: number;
|
||||
end: number;
|
||||
query: string;
|
||||
handleQuery: string;
|
||||
domainQuery: string | null;
|
||||
}
|
||||
|
||||
export type RichTextToken =
|
||||
| { type: 'text'; value: string; start: number; end: number }
|
||||
| { type: 'url'; value: string; start: number; end: number }
|
||||
| ({ type: 'mention'; value: string } & ParsedMention);
|
||||
|
||||
function hasValidLeadingBoundary(content: string, start: number): boolean {
|
||||
if (start === 0) return true;
|
||||
return !/[a-zA-Z0-9_@.]/.test(content[start - 1]);
|
||||
}
|
||||
|
||||
function isInsideUrlLikeToken(content: string, start: number): boolean {
|
||||
const tokenStart = Math.max(
|
||||
content.lastIndexOf(' ', start - 1),
|
||||
content.lastIndexOf('\n', start - 1),
|
||||
content.lastIndexOf('\t', start - 1),
|
||||
) + 1;
|
||||
const prefix = content.slice(tokenStart, start).toLowerCase();
|
||||
return prefix.includes('://') || prefix.startsWith('mailto:');
|
||||
}
|
||||
|
||||
function normalizeMatchedDomain(value: string): string | null {
|
||||
let candidate = value.toLowerCase();
|
||||
while (candidate.endsWith('.') && !isValidNodeDomain(candidate)) {
|
||||
candidate = candidate.slice(0, -1);
|
||||
}
|
||||
return isValidNodeDomain(candidate) ? candidate : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse valid local and federated mentions with source ranges.
|
||||
*
|
||||
* The parser deliberately rejects email addresses, mentions embedded in URLs,
|
||||
* overlong handles, malformed domains, and partial qualified handles. Ranges
|
||||
* exclude sentence punctuation so rendering and composer replacement remain
|
||||
* exact.
|
||||
*/
|
||||
export function parseMentions(
|
||||
content: string,
|
||||
currentDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:43821',
|
||||
): ParsedMention[] {
|
||||
const mentions: ParsedMention[] = [];
|
||||
MENTION_PATTERN.lastIndex = 0;
|
||||
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = MENTION_PATTERN.exec(content)) !== null) {
|
||||
const start = match.index;
|
||||
const handle = match[1].toLowerCase();
|
||||
if (!HANDLE_PATTERN.test(handle)
|
||||
|| !hasValidLeadingBoundary(content, start)
|
||||
|| isInsideUrlLikeToken(content, start)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const handleEnd = start + 1 + match[1].length;
|
||||
const rawDomain = match[2];
|
||||
const domain = rawDomain ? normalizeMatchedDomain(rawDomain) : null;
|
||||
|
||||
// An explicit but malformed/partial domain must never fall back to a local
|
||||
// mention. That would notify the wrong person while the author is typing.
|
||||
if (rawDomain && !domain) continue;
|
||||
if (!rawDomain && content[handleEnd] === '@') continue;
|
||||
|
||||
const end = domain
|
||||
? handleEnd + 1 + domain.length
|
||||
: handleEnd;
|
||||
const next = content[end];
|
||||
|
||||
if (next && HANDLE_CHARACTER.test(next)) continue;
|
||||
if (!domain && next === '.' && /[a-zA-Z0-9]/.test(content[end + 1] || '')) continue;
|
||||
|
||||
const qualified = domain ? `${handle}@${domain}` : handle;
|
||||
const resolution = resolveUserHandle(qualified, currentDomain);
|
||||
|
||||
mentions.push({
|
||||
raw: content.slice(start, end),
|
||||
start,
|
||||
end,
|
||||
handle,
|
||||
domain,
|
||||
canonicalHandle: resolution.canonicalHandle,
|
||||
isQualified: Boolean(domain),
|
||||
isLocal: resolution.isLocal,
|
||||
});
|
||||
}
|
||||
|
||||
return mentions;
|
||||
}
|
||||
|
||||
export function uniqueMentions(mentions: readonly ParsedMention[]): ParsedMention[] {
|
||||
const seen = new Set<string>();
|
||||
return mentions.filter((mention) => {
|
||||
const key = mention.canonicalHandle.toLowerCase();
|
||||
if (seen.has(key)) return false;
|
||||
seen.add(key);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
/** Return the mention fragment immediately before the caret, if any. */
|
||||
export function getActiveMentionQuery(content: string, caret: number): ActiveMentionQuery | null {
|
||||
const safeCaret = Math.max(0, Math.min(caret, content.length));
|
||||
const beforeCaret = content.slice(0, safeCaret);
|
||||
const match = beforeCaret.match(/(?:^|[^a-zA-Z0-9_@.])@([a-zA-Z0-9_]{0,30})(?:@([a-zA-Z0-9.-]*(?::\d{0,5})?))?$/);
|
||||
if (!match || match.index === undefined) return null;
|
||||
|
||||
const boundaryLength = match[0].startsWith('@') ? 0 : 1;
|
||||
const start = match.index + boundaryLength;
|
||||
if (isInsideUrlLikeToken(content, start)) return null;
|
||||
|
||||
const handleQuery = match[1] || '';
|
||||
const domainQuery = match[2] === undefined ? null : match[2];
|
||||
return {
|
||||
start,
|
||||
end: safeCaret,
|
||||
query: domainQuery === null ? handleQuery : `${handleQuery}@${domainQuery}`,
|
||||
handleQuery,
|
||||
domainQuery,
|
||||
};
|
||||
}
|
||||
|
||||
export function replaceMentionQuery(
|
||||
content: string,
|
||||
active: ActiveMentionQuery,
|
||||
replacement: string,
|
||||
): { content: string; caret: number } {
|
||||
const normalizedReplacement = replacement.startsWith('@') ? replacement : `@${replacement}`;
|
||||
const needsSpace = active.end >= content.length || !/^\s/.test(content[active.end]);
|
||||
const inserted = `${normalizedReplacement}${needsSpace ? ' ' : ''}`;
|
||||
return {
|
||||
content: `${content.slice(0, active.start)}${inserted}${content.slice(active.end)}`,
|
||||
caret: active.start + inserted.length,
|
||||
};
|
||||
}
|
||||
|
||||
function trimUrlEnd(value: string): string {
|
||||
let result = value;
|
||||
while (/[.,!?;:]$/.test(result)) result = result.slice(0, -1);
|
||||
while (result.endsWith(')')) {
|
||||
const opens = (result.match(/\(/g) || []).length;
|
||||
const closes = (result.match(/\)/g) || []).length;
|
||||
if (closes <= opens) break;
|
||||
result = result.slice(0, -1);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Tokenize post text while giving URLs precedence over mentions inside URLs. */
|
||||
export function tokenizePostContent(
|
||||
content: string,
|
||||
currentDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:43821',
|
||||
): RichTextToken[] {
|
||||
const ranges: Array<RichTextToken> = [];
|
||||
URL_PATTERN.lastIndex = 0;
|
||||
let urlMatch: RegExpExecArray | null;
|
||||
while ((urlMatch = URL_PATTERN.exec(content)) !== null) {
|
||||
const value = trimUrlEnd(urlMatch[0]);
|
||||
if (!value) continue;
|
||||
ranges.push({
|
||||
type: 'url',
|
||||
value,
|
||||
start: urlMatch.index,
|
||||
end: urlMatch.index + value.length,
|
||||
});
|
||||
}
|
||||
|
||||
const urlRanges = ranges.filter((token) => token.type === 'url');
|
||||
for (const mention of parseMentions(content, currentDomain)) {
|
||||
const overlapsUrl = urlRanges.some((url) => mention.start < url.end && mention.end > url.start);
|
||||
if (!overlapsUrl) {
|
||||
ranges.push({ type: 'mention', value: mention.raw, ...mention });
|
||||
}
|
||||
}
|
||||
|
||||
ranges.sort((left, right) => left.start - right.start || right.end - left.end);
|
||||
const tokens: RichTextToken[] = [];
|
||||
let cursor = 0;
|
||||
for (const range of ranges) {
|
||||
if (range.start < cursor) continue;
|
||||
if (range.start > cursor) {
|
||||
tokens.push({ type: 'text', value: content.slice(cursor, range.start), start: cursor, end: range.start });
|
||||
}
|
||||
tokens.push(range);
|
||||
cursor = range.end;
|
||||
}
|
||||
if (cursor < content.length) {
|
||||
tokens.push({ type: 'text', value: content.slice(cursor), start: cursor, end: content.length });
|
||||
}
|
||||
return tokens;
|
||||
}
|
||||
@@ -52,4 +52,18 @@ describe('browser notification presentation', () => {
|
||||
url: '/notifications',
|
||||
});
|
||||
});
|
||||
|
||||
it('links federated mentions to the source swarm post', () => {
|
||||
expect(getBrowserNotificationContent({
|
||||
id: 'notification-remote',
|
||||
type: 'mention',
|
||||
actor: { handle: 'alice@remote.example', displayName: 'Alice' },
|
||||
post: {
|
||||
id: 'swarm:remote.example:550e8400-e29b-41d4-a716-446655440000',
|
||||
content: 'Hello @bob@local.example',
|
||||
},
|
||||
})).toMatchObject({
|
||||
url: '/posts/swarm:remote.example:550e8400-e29b-41d4-a716-446655440000',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -18,6 +18,7 @@ import { filterBlockedDomains, isNodeBlocked, normalizeNodeDomain } from './node
|
||||
import { getPublicSwarmDomain } from './node-domain';
|
||||
import { safeFederationRequest } from './safe-federation-http';
|
||||
import { serializeLinkPreviewMedia } from '@/lib/media/linkPreview';
|
||||
import { parseMentions, uniqueMentions } from '@/lib/mentions/parser';
|
||||
|
||||
// ============================================
|
||||
// TYPES
|
||||
@@ -53,6 +54,8 @@ export interface SwarmInteractionResponse {
|
||||
success: boolean;
|
||||
message?: string;
|
||||
error?: string;
|
||||
statusCode?: number;
|
||||
retryable?: boolean;
|
||||
}
|
||||
|
||||
export interface SwarmLikePayload {
|
||||
@@ -130,6 +133,8 @@ export interface SwarmMentionPayload {
|
||||
actorDisplayName: string;
|
||||
actorAvatarUrl?: string;
|
||||
actorNodeDomain: string;
|
||||
actorDid?: string;
|
||||
actorPublicKey?: string;
|
||||
postId: string;
|
||||
postContent: string;
|
||||
interactionId: string;
|
||||
@@ -273,6 +278,8 @@ async function deliverSwarmInteraction(
|
||||
if (await isNodeBlocked(normalizedTargetDomain)) {
|
||||
return {
|
||||
success: false,
|
||||
statusCode: 403,
|
||||
retryable: false,
|
||||
error: `Blocked node: ${normalizedTargetDomain}`,
|
||||
};
|
||||
}
|
||||
@@ -295,7 +302,9 @@ async function deliverSwarmInteraction(
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 10000); // 10s timeout
|
||||
|
||||
const response = await fetch(url, {
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
@@ -304,13 +313,16 @@ async function deliverSwarmInteraction(
|
||||
body: JSON.stringify(signedPayload),
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text().catch(() => 'Unknown error');
|
||||
return {
|
||||
success: false,
|
||||
statusCode: response.status,
|
||||
retryable: response.status === 408 || response.status === 429 || response.status >= 500,
|
||||
error: `HTTP ${response.status}: ${errorText}`,
|
||||
};
|
||||
}
|
||||
@@ -323,6 +335,7 @@ async function deliverSwarmInteraction(
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
retryable: true,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
};
|
||||
}
|
||||
@@ -620,19 +633,7 @@ export async function fetchSwarmPost(
|
||||
* Returns array of { handle, domain } for remote mentions
|
||||
*/
|
||||
export function extractMentions(content: string): { handle: string; domain: string | null }[] {
|
||||
// Match @handle or @handle@domain patterns
|
||||
const mentionRegex = /@([a-zA-Z0-9_]+)(?:@([a-zA-Z0-9.-]+))?/g;
|
||||
const mentions: { handle: string; domain: string | null }[] = [];
|
||||
|
||||
let match;
|
||||
while ((match = mentionRegex.exec(content)) !== null) {
|
||||
mentions.push({
|
||||
handle: match[1].toLowerCase(),
|
||||
domain: match[2]?.toLowerCase() || null,
|
||||
});
|
||||
}
|
||||
|
||||
return mentions;
|
||||
return parseMentions(content).map(({ handle, domain }) => ({ handle, domain }));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -648,13 +649,13 @@ export async function deliverSwarmMentions(
|
||||
nodeDomain: string;
|
||||
}
|
||||
): Promise<{ delivered: number; failed: number }> {
|
||||
const mentions = extractMentions(content);
|
||||
const mentions = uniqueMentions(parseMentions(content, actor.nodeDomain));
|
||||
let delivered = 0;
|
||||
let failed = 0;
|
||||
|
||||
for (const mention of mentions) {
|
||||
// Skip local mentions (no domain)
|
||||
if (!mention.domain) continue;
|
||||
// Local and same-node-qualified mentions are handled synchronously.
|
||||
if (mention.isLocal || !mention.domain) continue;
|
||||
|
||||
// Check if it's a swarm node
|
||||
const isSwarm = await isSwarmNode(mention.domain);
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
const localHandlePattern = /^[a-zA-Z0-9_]{3,20}$/;
|
||||
const localHandlePattern = /^[a-zA-Z0-9_]{3,30}$/;
|
||||
const hostnameLabel = '[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?';
|
||||
const nodeDomainPattern = `(?:localhost|127\\.0\\.0\\.1|${hostnameLabel}(?:\\.${hostnameLabel})+)(?::\\d{1,5})?`;
|
||||
const federatedHandlePattern = new RegExp(`^[a-zA-Z0-9_]{3,20}(?:@${nodeDomainPattern})?$`);
|
||||
const federatedHandlePattern = new RegExp(`^[a-zA-Z0-9_]{3,30}(?:@${nodeDomainPattern})?$`);
|
||||
const nodeDomainRegex = new RegExp(`^${nodeDomainPattern}$`);
|
||||
|
||||
export const localHandleSchema = z
|
||||
.string()
|
||||
.min(3)
|
||||
.max(20)
|
||||
.regex(localHandlePattern, 'Handle must be 3-20 characters, alphanumeric and underscores only');
|
||||
.max(30)
|
||||
.regex(localHandlePattern, 'Handle must be 3-30 characters, alphanumeric and underscores only');
|
||||
|
||||
export const federatedHandleSchema = z
|
||||
.string()
|
||||
|
||||
Reference in New Issue
Block a user