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 payload = rows.map((row) => {
|
||||||
const key = row.actorNodeDomain ? `${row.actorHandle}@${row.actorNodeDomain}` : null;
|
const key = row.actorNodeDomain ? `${row.actorHandle}@${row.actorNodeDomain}` : null;
|
||||||
const freshProfile = key ? freshProfiles.get(key) : null;
|
const freshProfile = key ? freshProfiles.get(key) : null;
|
||||||
|
const remotePostReference = row.remotePostId && row.remotePostDomain
|
||||||
|
? `swarm:${row.remotePostDomain}:${row.remotePostId}`
|
||||||
|
: null;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: row.id,
|
id: row.id,
|
||||||
@@ -114,10 +117,12 @@ export async function GET(request: Request) {
|
|||||||
nodeDomain: row.targetNodeDomain,
|
nodeDomain: row.targetNodeDomain,
|
||||||
isBot: row.targetIsBot,
|
isBot: row.targetIsBot,
|
||||||
} : null,
|
} : null,
|
||||||
post: row.postId ? {
|
post: row.postId || remotePostReference ? {
|
||||||
id: row.postId,
|
id: row.postId || remotePostReference!,
|
||||||
content: row.post?.content || row.postContent,
|
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) => ({
|
media: row.post?.media.map((item) => ({
|
||||||
url: item.url,
|
url: item.url,
|
||||||
mimeType: item.mimeType,
|
mimeType: item.mimeType,
|
||||||
|
|||||||
@@ -14,6 +14,14 @@ vi.mock('@/lib/auth/verify-signature', () => ({
|
|||||||
requireSignedAction: vi.fn(),
|
requireSignedAction: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/lib/mentions/delivery', () => ({
|
||||||
|
registerPostMentions: vi.fn().mockResolvedValue({
|
||||||
|
localNotifications: 0,
|
||||||
|
remoteQueued: 0,
|
||||||
|
skipped: 0,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
vi.mock('@/db', () => ({
|
vi.mock('@/db', () => ({
|
||||||
db: {
|
db: {
|
||||||
insert: vi.fn(() => ({
|
insert: vi.fn(() => ({
|
||||||
@@ -80,8 +88,9 @@ describe('POST /api/posts', () => {
|
|||||||
},
|
},
|
||||||
did: 'did:synapsis:test123',
|
did: 'did:synapsis:test123',
|
||||||
handle: 'testuser',
|
handle: 'testuser',
|
||||||
timestamp: new Date().toISOString(),
|
ts: Date.now(),
|
||||||
signature: 'test-signature',
|
nonce: 'nonce-1',
|
||||||
|
sig: 'test-signature',
|
||||||
};
|
};
|
||||||
|
|
||||||
// Create a mock request
|
// Create a mock request
|
||||||
@@ -118,8 +127,9 @@ describe('POST /api/posts', () => {
|
|||||||
},
|
},
|
||||||
did: 'did:synapsis:test123',
|
did: 'did:synapsis:test123',
|
||||||
handle: 'testuser',
|
handle: 'testuser',
|
||||||
timestamp: new Date().toISOString(),
|
ts: Date.now(),
|
||||||
signature: 'invalid-signature',
|
nonce: 'nonce-2',
|
||||||
|
sig: 'invalid-signature',
|
||||||
};
|
};
|
||||||
|
|
||||||
const request = new Request('http://localhost:43821/api/posts', {
|
const request = new Request('http://localhost:43821/api/posts', {
|
||||||
@@ -148,8 +158,9 @@ describe('POST /api/posts', () => {
|
|||||||
},
|
},
|
||||||
did: 'did:synapsis:nonexistent',
|
did: 'did:synapsis:nonexistent',
|
||||||
handle: 'nonexistent',
|
handle: 'nonexistent',
|
||||||
timestamp: new Date().toISOString(),
|
ts: Date.now(),
|
||||||
signature: 'test-signature',
|
nonce: 'nonce-3',
|
||||||
|
sig: 'test-signature',
|
||||||
};
|
};
|
||||||
|
|
||||||
const request = new Request('http://localhost:43821/api/posts', {
|
const request = new Request('http://localhost:43821/api/posts', {
|
||||||
@@ -178,8 +189,9 @@ describe('POST /api/posts', () => {
|
|||||||
},
|
},
|
||||||
did: 'did:synapsis:test123',
|
did: 'did:synapsis:test123',
|
||||||
handle: 'wronghandle',
|
handle: 'wronghandle',
|
||||||
timestamp: new Date().toISOString(),
|
ts: Date.now(),
|
||||||
signature: 'test-signature',
|
nonce: 'nonce-4',
|
||||||
|
sig: 'test-signature',
|
||||||
};
|
};
|
||||||
|
|
||||||
const request = new Request('http://localhost:43821/api/posts', {
|
const request = new Request('http://localhost:43821/api/posts', {
|
||||||
@@ -208,8 +220,9 @@ describe('POST /api/posts', () => {
|
|||||||
},
|
},
|
||||||
did: 'did:synapsis:test123',
|
did: 'did:synapsis:test123',
|
||||||
handle: 'testuser',
|
handle: 'testuser',
|
||||||
timestamp: new Date(Date.now() - 10 * 60 * 1000).toISOString(), // 10 minutes ago
|
ts: Date.now() - 10 * 60 * 1000,
|
||||||
signature: 'test-signature',
|
nonce: 'nonce-5',
|
||||||
|
sig: 'test-signature',
|
||||||
};
|
};
|
||||||
|
|
||||||
const request = new Request('http://localhost:43821/api/posts', {
|
const request = new Request('http://localhost:43821/api/posts', {
|
||||||
@@ -249,8 +262,9 @@ describe('POST /api/posts', () => {
|
|||||||
},
|
},
|
||||||
did: 'did:synapsis:test123',
|
did: 'did:synapsis:test123',
|
||||||
handle: 'testuser',
|
handle: 'testuser',
|
||||||
timestamp: new Date().toISOString(),
|
ts: Date.now(),
|
||||||
signature: 'test-signature',
|
nonce: 'nonce-6',
|
||||||
|
sig: 'test-signature',
|
||||||
};
|
};
|
||||||
|
|
||||||
const request = new Request('http://localhost:43821/api/posts', {
|
const request = new Request('http://localhost:43821/api/posts', {
|
||||||
@@ -289,8 +303,9 @@ describe('POST /api/posts', () => {
|
|||||||
},
|
},
|
||||||
did: 'did:synapsis:test123',
|
did: 'did:synapsis:test123',
|
||||||
handle: 'testuser',
|
handle: 'testuser',
|
||||||
timestamp: new Date().toISOString(),
|
ts: Date.now(),
|
||||||
signature: 'test-signature',
|
nonce: 'nonce-7',
|
||||||
|
sig: 'test-signature',
|
||||||
};
|
};
|
||||||
|
|
||||||
const request = new Request('http://localhost:43821/api/posts', {
|
const request = new Request('http://localhost:43821/api/posts', {
|
||||||
|
|||||||
+21
-79
@@ -16,6 +16,7 @@ import {
|
|||||||
CURATED_FEED_WINDOW_HOURS,
|
CURATED_FEED_WINDOW_HOURS,
|
||||||
rankCuratedFeed,
|
rankCuratedFeed,
|
||||||
} from '@/lib/posts/curated-feed';
|
} from '@/lib/posts/curated-feed';
|
||||||
|
import { registerPostMentions } from '@/lib/mentions/delivery';
|
||||||
|
|
||||||
const POST_MAX_LENGTH = 600;
|
const POST_MAX_LENGTH = 600;
|
||||||
const CURATION_SEED_MULTIPLIER = 5;
|
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)
|
// Resolve local mentions and durably enqueue federated delivery before
|
||||||
(async () => {
|
// returning. Remote network I/O is retried from the persistent outbox.
|
||||||
try {
|
try {
|
||||||
const { extractMentions } = await import('@/lib/swarm/interactions');
|
await registerPostMentions({
|
||||||
const mentions = extractMentions(postContent);
|
postId: post.id,
|
||||||
|
content: postContent,
|
||||||
for (const mention of mentions) {
|
actor: {
|
||||||
// Only handle local mentions (no domain)
|
id: user.id,
|
||||||
if (mention.domain) continue;
|
handle: user.handle,
|
||||||
|
displayName: user.displayName,
|
||||||
// Find the mentioned user
|
avatarUrl: user.avatarUrl,
|
||||||
const mentionedUser = await db.query.users.findFirst({
|
did: user.did,
|
||||||
where: { handle: mention.handle.toLowerCase() },
|
publicKey: user.publicKey,
|
||||||
});
|
},
|
||||||
|
nodeDomain,
|
||||||
if (mentionedUser && mentionedUser.id !== user.id && !mentionedUser.isSuspended) {
|
});
|
||||||
// Create notification for the mentioned user with actor info stored directly
|
} catch (err) {
|
||||||
await db.insert(notifications).values({
|
console.error('[Posts] Error registering mentions:', err);
|
||||||
userId: mentionedUser.id,
|
console.error('[Posts] Context:', { postId: post.id, userId: user.id, content: postContent.slice(0, 100) });
|
||||||
actorId: user.id,
|
}
|
||||||
actorHandle: user.handle,
|
|
||||||
actorDisplayName: user.displayName,
|
|
||||||
actorAvatarUrl: user.avatarUrl,
|
|
||||||
actorNodeDomain: null, // Local user
|
|
||||||
postId: post.id,
|
|
||||||
postContent: post.content?.slice(0, 200) || null,
|
|
||||||
...(mentionedUser.isBot ? buildNotificationTarget(mentionedUser) : {}),
|
|
||||||
type: 'mention',
|
|
||||||
});
|
|
||||||
|
|
||||||
// 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] 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)
|
// Federate the post to remote followers (non-blocking)
|
||||||
(async () => {
|
(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 { NextRequest, NextResponse } from 'next/server';
|
||||||
import { db, users, notifications } from '@/db';
|
import { db, notifications } from '@/db';
|
||||||
import { eq } from 'drizzle-orm';
|
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { verifySwarmRequest } from '@/lib/swarm/signature';
|
import { verifySwarmRequest } from '@/lib/swarm/signature';
|
||||||
import { localHandleSchema, nodeDomainSchema } from '@/lib/utils/federation';
|
import { localHandleSchema, nodeDomainSchema } from '@/lib/utils/federation';
|
||||||
import { buildNotificationTarget } from '@/lib/notifications';
|
import { buildNotificationTarget } from '@/lib/notifications';
|
||||||
|
import { normalizeNodeDomain } from '@/lib/swarm/node-domain';
|
||||||
|
|
||||||
const swarmMentionSchema = z.object({
|
const swarmMentionSchema = z.object({
|
||||||
mentionedHandle: localHandleSchema,
|
mentionedHandle: localHandleSchema,
|
||||||
@@ -21,6 +21,8 @@ const swarmMentionSchema = z.object({
|
|||||||
actorDisplayName: z.string().min(1).max(50),
|
actorDisplayName: z.string().min(1).max(50),
|
||||||
actorAvatarUrl: z.string().url().optional(),
|
actorAvatarUrl: z.string().url().optional(),
|
||||||
actorNodeDomain: nodeDomainSchema,
|
actorNodeDomain: nodeDomainSchema,
|
||||||
|
actorDid: z.string().min(1).max(500).optional(),
|
||||||
|
actorPublicKey: z.string().min(1).max(5000).optional(),
|
||||||
postId: z.string().uuid(),
|
postId: z.string().uuid(),
|
||||||
postContent: z.string().max(10000),
|
postContent: z.string().max(10000),
|
||||||
interactionId: z.string().uuid(),
|
interactionId: z.string().uuid(),
|
||||||
@@ -29,6 +31,36 @@ const swarmMentionSchema = z.object({
|
|||||||
signature: z.string(),
|
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
|
* POST /api/swarm/interactions/mention
|
||||||
*
|
*
|
||||||
@@ -65,39 +97,53 @@ export async function POST(request: NextRequest) {
|
|||||||
return NextResponse.json({ error: 'User not found' }, { status: 404 });
|
return NextResponse.json({ error: 'User not found' }, { status: 404 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create notification with actor info stored directly
|
const actorDomain = normalizeNodeDomain(data.mention.actorNodeDomain);
|
||||||
try {
|
const interactionKey = `mention:remote:${actorDomain}:${data.mention.interactionId}`;
|
||||||
await db.insert(notifications).values({
|
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,
|
userId: mentionedUser.id,
|
||||||
actorHandle: data.mention.actorHandle,
|
actorHandle: data.mention.actorHandle,
|
||||||
actorDisplayName: data.mention.actorDisplayName,
|
actorDisplayName: data.mention.actorDisplayName,
|
||||||
actorAvatarUrl: data.mention.actorAvatarUrl || null,
|
actorAvatarUrl: data.mention.actorAvatarUrl || null,
|
||||||
actorNodeDomain: data.mention.actorNodeDomain,
|
actorNodeDomain: actorDomain,
|
||||||
|
remotePostId: data.mention.postId,
|
||||||
|
remotePostDomain: actorDomain,
|
||||||
postContent: data.mention.postContent.slice(0, 200),
|
postContent: data.mention.postContent.slice(0, 200),
|
||||||
|
interactionId: interactionKey,
|
||||||
...(mentionedUser.isBot ? buildNotificationTarget(mentionedUser) : {}),
|
...(mentionedUser.isBot ? buildNotificationTarget(mentionedUser) : {}),
|
||||||
type: 'mention',
|
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}`);
|
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
|
// Also notify bot owner if this is a bot being mentioned
|
||||||
if (mentionedUser.isBot && mentionedUser.botOwnerId) {
|
if (mentionedUser.isBot
|
||||||
try {
|
&& mentionedUser.botOwnerId
|
||||||
|
&& await acceptsRemoteMention(mentionedUser.botOwnerId, actorDomain, cachedActor?.id || null)) {
|
||||||
await db.insert(notifications).values({
|
await db.insert(notifications).values({
|
||||||
userId: mentionedUser.botOwnerId,
|
userId: mentionedUser.botOwnerId,
|
||||||
actorHandle: data.mention.actorHandle,
|
actorHandle: data.mention.actorHandle,
|
||||||
actorDisplayName: data.mention.actorDisplayName,
|
actorDisplayName: data.mention.actorDisplayName,
|
||||||
actorAvatarUrl: data.mention.actorAvatarUrl || null,
|
actorAvatarUrl: data.mention.actorAvatarUrl || null,
|
||||||
actorNodeDomain: data.mention.actorNodeDomain,
|
actorNodeDomain: actorDomain,
|
||||||
|
remotePostId: data.mention.postId,
|
||||||
|
remotePostDomain: actorDomain,
|
||||||
postContent: data.mention.postContent.slice(0, 200),
|
postContent: data.mention.postContent.slice(0, 200),
|
||||||
|
interactionId: `${interactionKey}:owner:${mentionedUser.id}`,
|
||||||
...buildNotificationTarget(mentionedUser),
|
...buildNotificationTarget(mentionedUser),
|
||||||
type: 'mention',
|
type: 'mention',
|
||||||
});
|
}).onConflictDoNothing();
|
||||||
} catch (err) {
|
|
||||||
console.error('[Swarm] Failed to notify bot owner:', err);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`[Swarm] Received mention from ${data.mention.actorHandle}@${data.mention.actorNodeDomain} for @${data.mentionedHandle}`);
|
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;
|
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 {
|
.repost-event-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -759,6 +776,7 @@ a.btn-primary:visited {
|
|||||||
.compose {
|
.compose {
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
border-bottom: 1px solid var(--border);
|
border-bottom: 1px solid var(--border);
|
||||||
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.compose-input {
|
.compose-input {
|
||||||
@@ -780,6 +798,101 @@ a.btn-primary:visited {
|
|||||||
color: var(--foreground-tertiary);
|
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 {
|
.compose-footer {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
'use client';
|
'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);
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||||
|
|
||||||
|
useImperativeHandle(forwardedRef, () => textareaRef.current as HTMLTextAreaElement, []);
|
||||||
|
|
||||||
const adjustHeight = () => {
|
const adjustHeight = () => {
|
||||||
const textarea = textareaRef.current;
|
const textarea = textareaRef.current;
|
||||||
if (textarea) {
|
if (textarea) {
|
||||||
@@ -35,4 +37,6 @@ export default function AutoTextarea(props: TextareaHTMLAttributes<HTMLTextAreaE
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
|
|
||||||
|
export default AutoTextarea;
|
||||||
|
|||||||
+181
-20
@@ -1,6 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useEffect, useRef } from 'react';
|
import { useState, useEffect, useId, useRef } from 'react';
|
||||||
import AutoTextarea from '@/components/AutoTextarea';
|
import AutoTextarea from '@/components/AutoTextarea';
|
||||||
import { Post, Attachment } from '@/lib/types';
|
import { Post, Attachment } from '@/lib/types';
|
||||||
import { AlertTriangle, Music2, Paperclip } from 'lucide-react';
|
import { AlertTriangle, Music2, Paperclip } from 'lucide-react';
|
||||||
@@ -10,14 +10,31 @@ import { useAuth } from '@/lib/contexts/AuthContext';
|
|||||||
import { StorageConfigurationPrompt } from '@/components/StorageConfigurationPrompt';
|
import { StorageConfigurationPrompt } from '@/components/StorageConfigurationPrompt';
|
||||||
import { getStorageProvider, MediaUploadError, uploadMediaFile } from '@/lib/stuffbox/browser-upload';
|
import { getStorageProvider, MediaUploadError, uploadMediaFile } from '@/lib/stuffbox/browser-upload';
|
||||||
import { getMediaKind } from '@/lib/media/upload-policy';
|
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 {
|
interface MediaAttachment extends Attachment {
|
||||||
mimeType?: string;
|
mimeType?: string;
|
||||||
filename?: string;
|
filename?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface MentionSuggestion {
|
||||||
|
handle: string;
|
||||||
|
displayName: string | null;
|
||||||
|
avatarUrl: string | null;
|
||||||
|
isBot: boolean;
|
||||||
|
isRemote: boolean;
|
||||||
|
nodeDomain: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
interface ComposeProps {
|
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;
|
onPosted?: () => void;
|
||||||
replyingTo?: Post | null;
|
replyingTo?: Post | null;
|
||||||
onCancelReply?: () => void;
|
onCancelReply?: () => void;
|
||||||
@@ -28,7 +45,7 @@ interface ComposeProps {
|
|||||||
|
|
||||||
export function Compose({ onPost, onPosted, replyingTo, onCancelReply, placeholder = "What's happening?", isReply, autoFocus = false }: ComposeProps) {
|
export function Compose({ onPost, onPosted, replyingTo, onCancelReply, placeholder = "What's happening?", isReply, autoFocus = false }: ComposeProps) {
|
||||||
const { isIdentityUnlocked } = useAuth();
|
const { isIdentityUnlocked } = useAuth();
|
||||||
const replyToHandle = replyingTo ? useFormattedHandle(replyingTo.author.handle) : '';
|
const replyToHandle = useFormattedHandle(replyingTo?.author.handle || '');
|
||||||
const [content, setContent] = useState('');
|
const [content, setContent] = useState('');
|
||||||
const [isPosting, setIsPosting] = useState(false);
|
const [isPosting, setIsPosting] = useState(false);
|
||||||
const [attachments, setAttachments] = useState<MediaAttachment[]>([]);
|
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 [storageNotice, setStorageNotice] = useState<string | null>(null);
|
||||||
const [pendingStorageFiles, setPendingStorageFiles] = useState<File[]>([]);
|
const [pendingStorageFiles, setPendingStorageFiles] = useState<File[]>([]);
|
||||||
const [showStorageConfiguration, setShowStorageConfiguration] = useState(false);
|
const [showStorageConfiguration, setShowStorageConfiguration] = useState(false);
|
||||||
const [linkPreview, setLinkPreview] = useState<any>(null);
|
const [linkPreview, setLinkPreview] = useState<LinkPreviewData | null>(null);
|
||||||
const [fetchingPreview, setFetchingPreview] = useState(false);
|
|
||||||
const [lastDetectedUrl, setLastDetectedUrl] = useState<string | null>(null);
|
const [lastDetectedUrl, setLastDetectedUrl] = useState<string | null>(null);
|
||||||
const [isNsfw, setIsNsfw] = useState(false);
|
const [isNsfw, setIsNsfw] = useState(false);
|
||||||
const [canPostNsfw, setCanPostNsfw] = useState(false);
|
const [canPostNsfw, setCanPostNsfw] = useState(false);
|
||||||
const [isNsfwNode, setIsNsfwNode] = useState(false);
|
const [isNsfwNode, setIsNsfwNode] = useState(false);
|
||||||
const mediaInputRef = useRef<HTMLInputElement>(null);
|
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 maxLength = 600;
|
||||||
const remaining = maxLength - content.length;
|
const remaining = maxLength - content.length;
|
||||||
const canSubmit = content.trim().length > 0 || attachments.length > 0;
|
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
|
// Detect URLs in content
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const urlRegex = /(?:https?:\/\/)?((?:[a-zA-Z0-9-]+\.)+[a-z]{2,63})\b([-a-zA-Z0-9@:%_\+.~#?&//=]*)/gi;
|
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]) {
|
if (matches[0]) {
|
||||||
const url = matches[0];
|
const url = matches[0][0];
|
||||||
if (url !== lastDetectedUrl) {
|
if (url !== lastDetectedUrl) {
|
||||||
setLastDetectedUrl(url);
|
setLastDetectedUrl(url);
|
||||||
fetchPreview(url);
|
fetchPreview(url);
|
||||||
@@ -94,8 +122,83 @@ export function Compose({ onPost, onPosted, replyingTo, onCancelReply, placehold
|
|||||||
}
|
}
|
||||||
}, [content, lastDetectedUrl]);
|
}, [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) => {
|
const fetchPreview = async (url: string) => {
|
||||||
setFetchingPreview(true);
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/media/preview?url=${encodeURIComponent(url)}`);
|
const res = await fetch(`/api/media/preview?url=${encodeURIComponent(url)}`);
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
@@ -104,8 +207,6 @@ export function Compose({ onPost, onPosted, replyingTo, onCancelReply, placehold
|
|||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Preview error', err);
|
console.error('Preview error', err);
|
||||||
} finally {
|
|
||||||
setFetchingPreview(false);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -120,7 +221,7 @@ export function Compose({ onPost, onPosted, replyingTo, onCancelReply, placehold
|
|||||||
|
|
||||||
setIsPosting(true);
|
setIsPosting(true);
|
||||||
try {
|
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) {
|
if (posted === false) {
|
||||||
setIsPosting(false);
|
setIsPosting(false);
|
||||||
return;
|
return;
|
||||||
@@ -236,14 +337,74 @@ export function Compose({ onPost, onPosted, replyingTo, onCancelReply, placehold
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<AutoTextarea
|
<div className="compose-mention-field">
|
||||||
className="compose-input"
|
<AutoTextarea
|
||||||
placeholder={placeholder}
|
ref={textareaRef}
|
||||||
value={content}
|
className="compose-input"
|
||||||
onChange={(e) => setContent(e.target.value)}
|
placeholder={placeholder}
|
||||||
maxLength={maxLength + 50} // Allow some overflow for better UX
|
value={content}
|
||||||
autoFocus={autoFocus}
|
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 && (
|
{attachments.length > 0 && (
|
||||||
<div className="compose-media-grid">
|
<div className="compose-media-grid">
|
||||||
{attachments.map((item) => {
|
{attachments.map((item) => {
|
||||||
|
|||||||
+25
-15
@@ -11,13 +11,14 @@ import { useToast } from '@/lib/contexts/ToastContext';
|
|||||||
import { VideoEmbed } from '@/components/VideoEmbed';
|
import { VideoEmbed } from '@/components/VideoEmbed';
|
||||||
import BlurredImage from '@/components/BlurredImage';
|
import BlurredImage from '@/components/BlurredImage';
|
||||||
import BlurredVideo from '@/components/BlurredVideo';
|
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 { useDomain } from '@/lib/contexts/ConfigContext';
|
||||||
import { signedAPI } from '@/lib/api/signed-fetch';
|
import { signedAPI } from '@/lib/api/signed-fetch';
|
||||||
import type { LinkPreviewData } from '@/lib/media/linkPreview';
|
import type { LinkPreviewData } from '@/lib/media/linkPreview';
|
||||||
import { AvatarImage } from '@/components/AvatarImage';
|
import { AvatarImage } from '@/components/AvatarImage';
|
||||||
import { AudioPlayer } from '@/components/AudioPlayer';
|
import { AudioPlayer } from '@/components/AudioPlayer';
|
||||||
import { getMediaKind } from '@/lib/media/upload-policy';
|
import { getMediaKind } from '@/lib/media/upload-policy';
|
||||||
|
import { tokenizePostContent } from '@/lib/mentions/parser';
|
||||||
|
|
||||||
// Component for link preview image that hides on error
|
// Component for link preview image that hides on error
|
||||||
function LinkPreviewImage({ src, alt }: { src: string; alt: string }) {
|
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 renderContent = (content: string, hidePreviewUrl?: string) => {
|
||||||
const decoded = decodeHtmlEntities(content);
|
const decoded = decodeHtmlEntities(content);
|
||||||
const parts = decoded.split(/(https?:\/\/[^\s]+)/g);
|
const tokens = tokenizePostContent(decoded, domain);
|
||||||
return parts.map((part, index) => {
|
return tokens.map((token, index) => {
|
||||||
if (part.match(/^https?:\/\/[^\s]+$/)) {
|
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 this URL matches the link preview URL, hide it entirely
|
||||||
if (hidePreviewUrl && part.includes(hidePreviewUrl.replace(/^https?:\/\/(www\.)?/, '').split('/')[0])) {
|
if (hidePreviewUrl && part.includes(hidePreviewUrl.replace(/^https?:\/\/(www\.)?/, '').split('/')[0])) {
|
||||||
return null;
|
return null;
|
||||||
@@ -622,16 +638,7 @@ export function PostCard({ post, onLike, onRepost, onComment, onDelete, onHide,
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Handle newlines
|
return <span key={`text-${token.start}-${index}`}>{token.value}</span>;
|
||||||
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>;
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -651,7 +658,10 @@ export function PostCard({ post, onLike, onRepost, onComment, onDelete, onHide,
|
|||||||
? JSON.parse(post.swarmReplyToAuthor)
|
? JSON.parse(post.swarmReplyToAuthor)
|
||||||
: post.swarmReplyToAuthor)?.nodeDomain,
|
: post.swarmReplyToAuthor)?.nodeDomain,
|
||||||
} as Post : null);
|
} 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 repostHandle = useFormattedHandle(post.author.handle, post.nodeDomain);
|
||||||
const hasOwnContent = decodeHtmlEntities(post.content).trim().length > 0;
|
const hasOwnContent = decodeHtmlEntities(post.content).trim().length > 0;
|
||||||
const isRepostEvent = Boolean(post.repostOf);
|
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 }),
|
likes: r.many.likes({ from: r.posts.id, to: r.likes.postId }),
|
||||||
media: r.many.media({ from: r.posts.id, to: r.media.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: {
|
media: {
|
||||||
user: r.one.users({ from: r.media.userId, to: r.users.id, optional: false }),
|
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 }),
|
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: {
|
sessions: {
|
||||||
user: r.one.users({ from: r.sessions.userId, to: r.users.id, optional: false }),
|
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' }),
|
targetIsBot: integer('target_is_bot', { mode: 'boolean' }),
|
||||||
// Post reference
|
// Post reference
|
||||||
postId: text('post_id').references(() => posts.id, { onDelete: 'cascade' }),
|
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
|
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
|
type: text('type').notNull(), // follow | like | repost | mention
|
||||||
readAt: integer('read_at', { mode: 'timestamp' }),
|
readAt: integer('read_at', { mode: 'timestamp' }),
|
||||||
createdAt: integer('created_at', { mode: 'timestamp' }).default(currentTimestamp).notNull(),
|
createdAt: integer('created_at', { mode: 'timestamp' }).default(currentTimestamp).notNull(),
|
||||||
}, (table) => [
|
}, (table) => [
|
||||||
index('notifications_user_idx').on(table.userId),
|
index('notifications_user_idx').on(table.userId),
|
||||||
index('notifications_created_idx').on(table.createdAt),
|
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_bot_idx').on(table.botId),
|
||||||
index('bot_mentions_processed_idx').on(table.isProcessed),
|
index('bot_mentions_processed_idx').on(table.isProcessed),
|
||||||
index('bot_mentions_created_idx').on(table.createdAt),
|
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 { getSwarmStats } from '@/lib/swarm/registry';
|
||||||
import { syncRemoteFollowsPosts } from '@/lib/background/remote-sync';
|
import { syncRemoteFollowsPosts } from '@/lib/background/remote-sync';
|
||||||
import { isPublicSwarmDomain } from '@/lib/swarm/node-domain';
|
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 BOT_INTERVAL_MS = 60 * 1000; // 1 minute
|
||||||
const GOSSIP_INTERVAL_MS = 5 * 60 * 1000; // 5 minutes
|
const GOSSIP_INTERVAL_MS = 5 * 60 * 1000; // 5 minutes
|
||||||
const REMOTE_SYNC_INTERVAL_MS = 60 * 1000; // 1 minute - keep feeds fresh
|
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
|
const STARTUP_DELAY_MS = 10 * 1000; // Wait 10s for server to be ready
|
||||||
|
|
||||||
let isStarted = false;
|
let isStarted = false;
|
||||||
@@ -33,6 +36,7 @@ function log(category: string, message: string, data?: unknown) {
|
|||||||
|
|
||||||
async function runBotTasks() {
|
async function runBotTasks() {
|
||||||
try {
|
try {
|
||||||
|
const mentions = await processAllActiveBotMentions();
|
||||||
const results = await processAllAutonomousBots();
|
const results = await processAllAutonomousBots();
|
||||||
|
|
||||||
const posted = results.filter(r => r.result.posted).length;
|
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}`);
|
const errorMsgs = results.filter(r => r.error).map(r => `${r.botHandle}: ${r.error}`);
|
||||||
log('BOTS', `Errors: ${errorMsgs.join('; ')}`);
|
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) {
|
} catch (error) {
|
||||||
log('BOTS', `Error: ${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() {
|
async function runSwarmGossip() {
|
||||||
try {
|
try {
|
||||||
const stats = await getSwarmStats();
|
const stats = await getSwarmStats();
|
||||||
@@ -140,12 +158,14 @@ export function startBackgroundTasks(origin?: string) {
|
|||||||
|
|
||||||
// Run initial bot check
|
// Run initial bot check
|
||||||
await runBotTasks();
|
await runBotTasks();
|
||||||
|
await runMentionDeliveries();
|
||||||
|
|
||||||
// Run initial remote sync (after 15s to let server stabilize)
|
// Run initial remote sync (after 15s to let server stabilize)
|
||||||
setTimeout(() => runRemoteSync(syncOrigin), 15 * 1000);
|
setTimeout(() => runRemoteSync(syncOrigin), 15 * 1000);
|
||||||
|
|
||||||
// Schedule recurring tasks
|
// Schedule recurring tasks
|
||||||
setInterval(runBotTasks, BOT_INTERVAL_MS);
|
setInterval(runBotTasks, BOT_INTERVAL_MS);
|
||||||
|
setInterval(runMentionDeliveries, MENTION_DELIVERY_INTERVAL_MS);
|
||||||
if (publicSwarmEnabled) {
|
if (publicSwarmEnabled) {
|
||||||
setInterval(runSwarmGossip, GOSSIP_INTERVAL_MS);
|
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,
|
responsePostId: null,
|
||||||
};
|
};
|
||||||
mentionsStore.set(id, mention);
|
mentionsStore.set(id, mention);
|
||||||
return {
|
const result = {
|
||||||
returning: vi.fn().mockResolvedValue([mention]),
|
returning: vi.fn().mockResolvedValue([mention]),
|
||||||
};
|
};
|
||||||
|
return {
|
||||||
|
...result,
|
||||||
|
onConflictDoNothing: vi.fn().mockReturnValue(result),
|
||||||
|
};
|
||||||
} else {
|
} else {
|
||||||
// This is a post insert
|
// This is a post insert
|
||||||
const id = `post-${++postIdCounter}`;
|
const id = `post-${++postIdCounter}`;
|
||||||
|
|||||||
+216
-37
@@ -7,11 +7,16 @@
|
|||||||
* Requirements: 7.1, 7.2, 7.3, 7.4, 7.5, 7.6
|
* Requirements: 7.1, 7.2, 7.3, 7.4, 7.5, 7.6
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { db, bots, botMentions, posts, users } from '@/db';
|
import { db, botMentions, notifications, posts, users } from '@/db';
|
||||||
import { eq, and, desc, asc, isNull } from 'drizzle-orm';
|
import { and, eq, isNull, lte, or, sql } from 'drizzle-orm';
|
||||||
import { ContentGenerator, type Bot as GeneratorBot, type Post as GeneratorPost } from './contentGenerator';
|
import { ContentGenerator, type Bot as GeneratorBot, type Post as GeneratorPost } from './contentGenerator';
|
||||||
import { canReply, recordReply } from './rateLimiter';
|
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
|
// 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.
|
* Mention detection result.
|
||||||
*/
|
*/
|
||||||
@@ -109,7 +126,7 @@ export async function detectMentions(botId: string): Promise<MentionDetectionRes
|
|||||||
where: { id: botId },
|
where: { id: botId },
|
||||||
with: {
|
with: {
|
||||||
user: {
|
user: {
|
||||||
columns: { handle: true },
|
columns: { id: true, handle: true },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
columns: { id: 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));
|
const existingPostIds = new Set(existingMentions.map(m => m.postId));
|
||||||
|
|
||||||
// Find posts that mention the bot's handle
|
// Direct post creation records bot mentions immediately. This bounded scan
|
||||||
// Note: In a production system, this would be more efficient with full-text search
|
// remains as a repair path for imported posts and older nodes.
|
||||||
// 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);
|
|
||||||
|
|
||||||
const recentPosts = await db.query.posts.findMany({
|
const recentPosts = await db.query.posts.findMany({
|
||||||
where: { AND: [{ isRemoved: false }] },
|
where: { AND: [{ isRemoved: false }] },
|
||||||
with: {
|
with: {
|
||||||
@@ -155,9 +165,12 @@ export async function detectMentions(botId: string): Promise<MentionDetectionRes
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Filter posts that mention the bot and aren't already tracked
|
// Filter posts that mention the bot and aren't already tracked
|
||||||
const newMentionPosts = recentPosts.filter(post =>
|
const newMentionPosts = recentPosts.filter((post) =>
|
||||||
post.content.includes(mentionPattern) &&
|
post.userId !== bot.user.id
|
||||||
!existingPostIds.has(post.id)
|
&& !existingPostIds.has(post.id)
|
||||||
|
&& uniqueMentions(parseMentions(post.content)).some(
|
||||||
|
(mention) => mention.isLocal && mention.handle === bot.user.handle.toLowerCase(),
|
||||||
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
// Create mention records
|
// Create mention records
|
||||||
@@ -171,7 +184,9 @@ export async function detectMentions(botId: string): Promise<MentionDetectionRes
|
|||||||
content: post.content,
|
content: post.content,
|
||||||
isProcessed: false,
|
isProcessed: false,
|
||||||
isRemote: false, // Local mentions for now
|
isRemote: false, // Local mentions for now
|
||||||
}).returning();
|
}).onConflictDoNothing().returning();
|
||||||
|
|
||||||
|
if (!mention) continue;
|
||||||
|
|
||||||
newMentions.push({
|
newMentions.push({
|
||||||
id: mention.id,
|
id: mention.id,
|
||||||
@@ -301,7 +316,7 @@ export async function getConversationContext(
|
|||||||
let depth = 0;
|
let depth = 0;
|
||||||
|
|
||||||
while (currentPostId && depth < maxDepth) {
|
while (currentPostId && depth < maxDepth) {
|
||||||
const post: any = await db.query.posts.findFirst({
|
const post = await db.query.posts.findFirst({
|
||||||
where: { id: currentPostId },
|
where: { id: currentPostId },
|
||||||
with: {
|
with: {
|
||||||
author: {
|
author: {
|
||||||
@@ -311,7 +326,7 @@ export async function getConversationContext(
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
}) as ConversationPostRow | undefined;
|
||||||
|
|
||||||
if (!post) break;
|
if (!post) break;
|
||||||
|
|
||||||
@@ -357,6 +372,8 @@ export async function getConversationContext(
|
|||||||
* Validates: Requirements 7.3, 7.4, 7.6
|
* Validates: Requirements 7.3, 7.4, 7.6
|
||||||
*/
|
*/
|
||||||
export async function processMention(mentionId: string): Promise<MentionResponseResult> {
|
export async function processMention(mentionId: string): Promise<MentionResponseResult> {
|
||||||
|
let ownsLease = false;
|
||||||
|
let completed = false;
|
||||||
try {
|
try {
|
||||||
// Get the mention
|
// Get the mention
|
||||||
const mention = await db.query.botMentions.findFirst({
|
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.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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
return {
|
||||||
success: true,
|
success: false,
|
||||||
responsePostId: mention.responsePostId || undefined,
|
error: 'Mention is already being processed',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
ownsLease = true;
|
||||||
|
|
||||||
// Check rate limits (Requirement 7.6)
|
// Check rate limits (Requirement 7.6)
|
||||||
const rateLimitCheck = await canReply(mention.botId);
|
const rateLimitCheck = await canReply(mention.botId);
|
||||||
@@ -395,6 +444,11 @@ export async function processMention(mentionId: string): Promise<MentionResponse
|
|||||||
columns: {
|
columns: {
|
||||||
id: true,
|
id: true,
|
||||||
handle: 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,
|
name: bot.name,
|
||||||
handle: bot.user.handle,
|
handle: bot.user.handle,
|
||||||
personalityConfig: JSON.parse(bot.personalityConfig),
|
personalityConfig: JSON.parse(bot.personalityConfig),
|
||||||
llmProvider: bot.llmProvider as any,
|
llmProvider: bot.llmProvider as LLMProvider,
|
||||||
llmModel: bot.llmModel,
|
llmModel: bot.llmModel,
|
||||||
llmEndpoint: bot.llmEndpoint,
|
llmEndpoint: bot.llmEndpoint,
|
||||||
llmApiKeyEncrypted: bot.llmApiKeyEncrypted,
|
llmApiKeyEncrypted: bot.llmApiKeyEncrypted,
|
||||||
@@ -465,29 +519,118 @@ export async function processMention(mentionId: string): Promise<MentionResponse
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Create response post
|
// Create response post
|
||||||
const [responsePost] = await db.insert(posts).values({
|
const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:43821';
|
||||||
userId: bot.user.id, // Bot posts as its associated user
|
const postUuid = mention.id;
|
||||||
content: generatedReply.text,
|
const responsePost = await db.transaction(async (tx) => {
|
||||||
replyToId: mention.postId,
|
const [createdPost] = await tx.insert(posts).values({
|
||||||
}).returning();
|
id: postUuid,
|
||||||
|
userId: bot.user.id, // Bot posts as its associated user
|
||||||
|
content: generatedReply.text,
|
||||||
|
replyToId: mention.postId,
|
||||||
|
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
|
if (!createdPost) {
|
||||||
await db.update(botMentions)
|
await tx.update(botMentions).set({
|
||||||
.set({
|
isProcessed: true,
|
||||||
isProcessed: true,
|
processedAt: new Date(),
|
||||||
processedAt: new Date(),
|
responsePostId: reply.id,
|
||||||
responsePostId: responsePost.id,
|
}).where(eq(botMentions.id, mentionId));
|
||||||
})
|
return reply;
|
||||||
.where(eq(botMentions.id, mentionId));
|
}
|
||||||
|
|
||||||
|
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: 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
|
// Record reply for rate limiting
|
||||||
await recordReply(mention.botId);
|
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 {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
responsePostId: responsePost.id,
|
responsePostId: responsePost.id,
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} 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) {
|
if (error instanceof MentionHandlerError) {
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
@@ -539,6 +682,42 @@ export async function processAllMentions(botId: string): Promise<MentionResponse
|
|||||||
return results;
|
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
|
// 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',
|
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 { getPublicSwarmDomain } from './node-domain';
|
||||||
import { safeFederationRequest } from './safe-federation-http';
|
import { safeFederationRequest } from './safe-federation-http';
|
||||||
import { serializeLinkPreviewMedia } from '@/lib/media/linkPreview';
|
import { serializeLinkPreviewMedia } from '@/lib/media/linkPreview';
|
||||||
|
import { parseMentions, uniqueMentions } from '@/lib/mentions/parser';
|
||||||
|
|
||||||
// ============================================
|
// ============================================
|
||||||
// TYPES
|
// TYPES
|
||||||
@@ -53,6 +54,8 @@ export interface SwarmInteractionResponse {
|
|||||||
success: boolean;
|
success: boolean;
|
||||||
message?: string;
|
message?: string;
|
||||||
error?: string;
|
error?: string;
|
||||||
|
statusCode?: number;
|
||||||
|
retryable?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SwarmLikePayload {
|
export interface SwarmLikePayload {
|
||||||
@@ -130,6 +133,8 @@ export interface SwarmMentionPayload {
|
|||||||
actorDisplayName: string;
|
actorDisplayName: string;
|
||||||
actorAvatarUrl?: string;
|
actorAvatarUrl?: string;
|
||||||
actorNodeDomain: string;
|
actorNodeDomain: string;
|
||||||
|
actorDid?: string;
|
||||||
|
actorPublicKey?: string;
|
||||||
postId: string;
|
postId: string;
|
||||||
postContent: string;
|
postContent: string;
|
||||||
interactionId: string;
|
interactionId: string;
|
||||||
@@ -273,6 +278,8 @@ async function deliverSwarmInteraction(
|
|||||||
if (await isNodeBlocked(normalizedTargetDomain)) {
|
if (await isNodeBlocked(normalizedTargetDomain)) {
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
|
statusCode: 403,
|
||||||
|
retryable: false,
|
||||||
error: `Blocked node: ${normalizedTargetDomain}`,
|
error: `Blocked node: ${normalizedTargetDomain}`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -295,22 +302,27 @@ async function deliverSwarmInteraction(
|
|||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
const timeout = setTimeout(() => controller.abort(), 10000); // 10s timeout
|
const timeout = setTimeout(() => controller.abort(), 10000); // 10s timeout
|
||||||
|
|
||||||
const response = await fetch(url, {
|
let response: Response;
|
||||||
method: 'POST',
|
try {
|
||||||
headers: {
|
response = await fetch(url, {
|
||||||
'Content-Type': 'application/json',
|
method: 'POST',
|
||||||
'Accept': 'application/json',
|
headers: {
|
||||||
},
|
'Content-Type': 'application/json',
|
||||||
body: JSON.stringify(signedPayload),
|
'Accept': 'application/json',
|
||||||
signal: controller.signal,
|
},
|
||||||
});
|
body: JSON.stringify(signedPayload),
|
||||||
|
signal: controller.signal,
|
||||||
clearTimeout(timeout);
|
});
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
}
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const errorText = await response.text().catch(() => 'Unknown error');
|
const errorText = await response.text().catch(() => 'Unknown error');
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
|
statusCode: response.status,
|
||||||
|
retryable: response.status === 408 || response.status === 429 || response.status >= 500,
|
||||||
error: `HTTP ${response.status}: ${errorText}`,
|
error: `HTTP ${response.status}: ${errorText}`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -323,6 +335,7 @@ async function deliverSwarmInteraction(
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
|
retryable: true,
|
||||||
error: error instanceof Error ? error.message : 'Unknown error',
|
error: error instanceof Error ? error.message : 'Unknown error',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -620,19 +633,7 @@ export async function fetchSwarmPost(
|
|||||||
* Returns array of { handle, domain } for remote mentions
|
* Returns array of { handle, domain } for remote mentions
|
||||||
*/
|
*/
|
||||||
export function extractMentions(content: string): { handle: string; domain: string | null }[] {
|
export function extractMentions(content: string): { handle: string; domain: string | null }[] {
|
||||||
// Match @handle or @handle@domain patterns
|
return parseMentions(content).map(({ handle, domain }) => ({ handle, domain }));
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -648,13 +649,13 @@ export async function deliverSwarmMentions(
|
|||||||
nodeDomain: string;
|
nodeDomain: string;
|
||||||
}
|
}
|
||||||
): Promise<{ delivered: number; failed: number }> {
|
): Promise<{ delivered: number; failed: number }> {
|
||||||
const mentions = extractMentions(content);
|
const mentions = uniqueMentions(parseMentions(content, actor.nodeDomain));
|
||||||
let delivered = 0;
|
let delivered = 0;
|
||||||
let failed = 0;
|
let failed = 0;
|
||||||
|
|
||||||
for (const mention of mentions) {
|
for (const mention of mentions) {
|
||||||
// Skip local mentions (no domain)
|
// Local and same-node-qualified mentions are handled synchronously.
|
||||||
if (!mention.domain) continue;
|
if (mention.isLocal || !mention.domain) continue;
|
||||||
|
|
||||||
// Check if it's a swarm node
|
// Check if it's a swarm node
|
||||||
const isSwarm = await isSwarmNode(mention.domain);
|
const isSwarm = await isSwarmNode(mention.domain);
|
||||||
|
|||||||
@@ -1,16 +1,16 @@
|
|||||||
import { z } from 'zod';
|
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 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 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}$`);
|
const nodeDomainRegex = new RegExp(`^${nodeDomainPattern}$`);
|
||||||
|
|
||||||
export const localHandleSchema = z
|
export const localHandleSchema = z
|
||||||
.string()
|
.string()
|
||||||
.min(3)
|
.min(3)
|
||||||
.max(20)
|
.max(30)
|
||||||
.regex(localHandlePattern, 'Handle must be 3-20 characters, alphanumeric and underscores only');
|
.regex(localHandlePattern, 'Handle must be 3-30 characters, alphanumeric and underscores only');
|
||||||
|
|
||||||
export const federatedHandleSchema = z
|
export const federatedHandleSchema = z
|
||||||
.string()
|
.string()
|
||||||
|
|||||||
Reference in New Issue
Block a user