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:
2026-07-15 16:13:34 -07:00
committed by Hop
parent a61fbbba12
commit d548d4bd6f
26 changed files with 9511 additions and 219 deletions
@@ -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
+231
View File
@@ -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 });
}
}
+8 -3
View File
@@ -91,6 +91,9 @@ export async function GET(request: Request) {
const payload = rows.map((row) => {
const key = row.actorNodeDomain ? `${row.actorHandle}@${row.actorNodeDomain}` : null;
const freshProfile = key ? freshProfiles.get(key) : null;
const remotePostReference = row.remotePostId && row.remotePostDomain
? `swarm:${row.remotePostDomain}:${row.remotePostId}`
: null;
return {
id: row.id,
@@ -114,10 +117,12 @@ export async function GET(request: Request) {
nodeDomain: row.targetNodeDomain,
isBot: row.targetIsBot,
} : null,
post: row.postId ? {
id: row.postId,
post: row.postId || remotePostReference ? {
id: row.postId || remotePostReference!,
content: row.post?.content || row.postContent,
authorHandle: row.post?.author?.handle || null,
authorHandle: row.post?.author?.handle || (row.actorNodeDomain
? `${row.actorHandle}@${row.actorNodeDomain}`
: row.actorHandle),
media: row.post?.media.map((item) => ({
url: item.url,
mimeType: item.mimeType,
+29 -14
View File
@@ -14,6 +14,14 @@ vi.mock('@/lib/auth/verify-signature', () => ({
requireSignedAction: vi.fn(),
}));
vi.mock('@/lib/mentions/delivery', () => ({
registerPostMentions: vi.fn().mockResolvedValue({
localNotifications: 0,
remoteQueued: 0,
skipped: 0,
}),
}));
vi.mock('@/db', () => ({
db: {
insert: vi.fn(() => ({
@@ -80,8 +88,9 @@ describe('POST /api/posts', () => {
},
did: 'did:synapsis:test123',
handle: 'testuser',
timestamp: new Date().toISOString(),
signature: 'test-signature',
ts: Date.now(),
nonce: 'nonce-1',
sig: 'test-signature',
};
// Create a mock request
@@ -118,8 +127,9 @@ describe('POST /api/posts', () => {
},
did: 'did:synapsis:test123',
handle: 'testuser',
timestamp: new Date().toISOString(),
signature: 'invalid-signature',
ts: Date.now(),
nonce: 'nonce-2',
sig: 'invalid-signature',
};
const request = new Request('http://localhost:43821/api/posts', {
@@ -148,8 +158,9 @@ describe('POST /api/posts', () => {
},
did: 'did:synapsis:nonexistent',
handle: 'nonexistent',
timestamp: new Date().toISOString(),
signature: 'test-signature',
ts: Date.now(),
nonce: 'nonce-3',
sig: 'test-signature',
};
const request = new Request('http://localhost:43821/api/posts', {
@@ -178,8 +189,9 @@ describe('POST /api/posts', () => {
},
did: 'did:synapsis:test123',
handle: 'wronghandle',
timestamp: new Date().toISOString(),
signature: 'test-signature',
ts: Date.now(),
nonce: 'nonce-4',
sig: 'test-signature',
};
const request = new Request('http://localhost:43821/api/posts', {
@@ -208,8 +220,9 @@ describe('POST /api/posts', () => {
},
did: 'did:synapsis:test123',
handle: 'testuser',
timestamp: new Date(Date.now() - 10 * 60 * 1000).toISOString(), // 10 minutes ago
signature: 'test-signature',
ts: Date.now() - 10 * 60 * 1000,
nonce: 'nonce-5',
sig: 'test-signature',
};
const request = new Request('http://localhost:43821/api/posts', {
@@ -249,8 +262,9 @@ describe('POST /api/posts', () => {
},
did: 'did:synapsis:test123',
handle: 'testuser',
timestamp: new Date().toISOString(),
signature: 'test-signature',
ts: Date.now(),
nonce: 'nonce-6',
sig: 'test-signature',
};
const request = new Request('http://localhost:43821/api/posts', {
@@ -289,8 +303,9 @@ describe('POST /api/posts', () => {
},
did: 'did:synapsis:test123',
handle: 'testuser',
timestamp: new Date().toISOString(),
signature: 'test-signature',
ts: Date.now(),
nonce: 'nonce-7',
sig: 'test-signature',
};
const request = new Request('http://localhost:43821/api/posts', {
+15 -73
View File
@@ -16,6 +16,7 @@ import {
CURATED_FEED_WINDOW_HOURS,
rankCuratedFeed,
} from '@/lib/posts/curated-feed';
import { registerPostMentions } from '@/lib/mentions/delivery';
const POST_MAX_LENGTH = 600;
const CURATION_SEED_MULTIPLIER = 5;
@@ -406,85 +407,26 @@ export async function POST(request: Request) {
}
}
// Handle local mentions (create notifications for users on this node)
(async () => {
// Resolve local mentions and durably enqueue federated delivery before
// returning. Remote network I/O is retried from the persistent outbox.
try {
const { extractMentions } = await import('@/lib/swarm/interactions');
const mentions = extractMentions(postContent);
for (const mention of mentions) {
// Only handle local mentions (no domain)
if (mention.domain) continue;
// Find the mentioned user
const mentionedUser = await db.query.users.findFirst({
where: { handle: mention.handle.toLowerCase() },
});
if (mentionedUser && mentionedUser.id !== user.id && !mentionedUser.isSuspended) {
// Create notification for the mentioned user with actor info stored directly
await db.insert(notifications).values({
userId: mentionedUser.id,
actorId: user.id,
actorHandle: user.handle,
actorDisplayName: user.displayName,
actorAvatarUrl: user.avatarUrl,
actorNodeDomain: null, // Local user
await registerPostMentions({
postId: post.id,
postContent: post.content?.slice(0, 200) || null,
...(mentionedUser.isBot ? buildNotificationTarget(mentionedUser) : {}),
type: 'mention',
content: postContent,
actor: {
id: user.id,
handle: user.handle,
displayName: user.displayName,
avatarUrl: user.avatarUrl,
did: user.did,
publicKey: user.publicKey,
},
nodeDomain,
});
// Also notify bot owner if this is a bot being mentioned
if (mentionedUser.isBot && mentionedUser.botOwnerId) {
await db.insert(notifications).values({
userId: mentionedUser.botOwnerId,
actorId: user.id,
actorHandle: user.handle,
actorDisplayName: user.displayName,
actorAvatarUrl: user.avatarUrl,
actorNodeDomain: null,
postId: post.id,
postContent: post.content?.slice(0, 200) || null,
...buildNotificationTarget(mentionedUser),
type: 'mention',
});
}
}
}
} catch (err) {
// Log error with context but don't fail the request - mention notifications are best-effort
console.error('[Posts] Error creating mention notifications:', err);
console.error('[Posts] Error registering mentions:', err);
console.error('[Posts] Context:', { postId: post.id, userId: user.id, content: postContent.slice(0, 100) });
}
})();
// SWARM-FIRST: Deliver mentions to swarm nodes
(async () => {
try {
const { deliverSwarmMentions } = await import('@/lib/swarm/interactions');
const result = await deliverSwarmMentions(
postContent,
post.id,
{
handle: user.handle,
displayName: user.displayName || user.handle,
avatarUrl: user.avatarUrl || undefined,
nodeDomain,
}
);
if (result.delivered > 0) {
console.log(`[Swarm] Delivered ${result.delivered} mentions (${result.failed} failed)`);
}
} catch (err) {
// Log error with context but don't fail the request - swarm delivery is best-effort
console.error('[Posts] Error delivering swarm mentions:', err);
console.error('[Posts] Context:', { postId: post.id, userId: user.id, nodeDomain });
}
})();
// Federate the post to remote followers (non-blocking)
(async () => {
@@ -0,0 +1,117 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
const mocks = vi.hoisted(() => ({
verifySwarmRequest: vi.fn(),
usersFindFirst: vi.fn(),
mutedNodeFindFirst: vi.fn(),
blockFindFirst: vi.fn(),
muteFindFirst: vi.fn(),
notificationValues: vi.fn(),
notificationReturning: vi.fn(),
}));
vi.mock('@/lib/swarm/signature', () => ({
verifySwarmRequest: mocks.verifySwarmRequest,
}));
vi.mock('@/db', () => ({
notifications: { id: 'id' },
db: {
query: {
users: { findFirst: mocks.usersFindFirst },
mutedNodes: { findFirst: mocks.mutedNodeFindFirst },
blocks: { findFirst: mocks.blockFindFirst },
mutes: { findFirst: mocks.muteFindFirst },
},
insert: vi.fn(() => ({
values: (values: unknown) => {
mocks.notificationValues(values);
return {
onConflictDoNothing: () => ({
returning: mocks.notificationReturning,
then: (resolve: (value: unknown) => unknown) => Promise.resolve(undefined).then(resolve),
}),
};
},
})),
},
}));
import { POST } from './route';
const interactionId = '550e8400-e29b-41d4-a716-446655440000';
const postId = '6ba7b810-9dad-41d1-80b4-00c04fd430c8';
function request() {
return new Request('https://local.example/api/swarm/interactions/mention', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
mentionedHandle: 'localuser',
mention: {
actorHandle: 'remoteuser',
actorDisplayName: 'Remote User',
actorNodeDomain: 'remote.example',
postId,
postContent: 'Hello @localuser@local.example',
interactionId,
timestamp: '2026-07-15T22:00:00.000Z',
},
signature: 'signed',
}),
});
}
describe('swarm mention receiver', () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.verifySwarmRequest.mockResolvedValue(true);
mocks.usersFindFirst
.mockResolvedValueOnce({
id: 'recipient-id',
handle: 'localuser',
isSuspended: false,
isBot: false,
botOwnerId: null,
})
.mockResolvedValueOnce(null);
mocks.mutedNodeFindFirst.mockResolvedValue(null);
mocks.blockFindFirst.mockResolvedValue(null);
mocks.muteFindFirst.mockResolvedValue(null);
mocks.notificationReturning.mockResolvedValue([{ id: 'notification-id' }]);
});
it('stores an idempotent, navigable remote post reference', async () => {
const response = await POST(request() as never);
expect(response.status).toBe(200);
expect(mocks.notificationValues).toHaveBeenCalledWith(expect.objectContaining({
userId: 'recipient-id',
interactionId: `mention:remote:remote.example:${interactionId}`,
remotePostId: postId,
remotePostDomain: 'remote.example',
actorNodeDomain: 'remote.example',
type: 'mention',
}));
expect(mocks.notificationReturning).toHaveBeenCalledOnce();
});
it('acknowledges but suppresses a mention from a muted node', async () => {
mocks.mutedNodeFindFirst.mockResolvedValue({ id: 'mute-id' });
const response = await POST(request() as never);
expect(response.status).toBe(200);
expect(await response.json()).toMatchObject({ success: true });
expect(mocks.notificationValues).not.toHaveBeenCalled();
});
it('rejects an invalid node signature before resolving the recipient', async () => {
mocks.verifySwarmRequest.mockResolvedValue(false);
const response = await POST(request() as never);
expect(response.status).toBe(403);
expect(mocks.usersFindFirst).not.toHaveBeenCalled();
});
});
+62 -16
View File
@@ -7,12 +7,12 @@
*/
import { NextRequest, NextResponse } from 'next/server';
import { db, users, notifications } from '@/db';
import { eq } from 'drizzle-orm';
import { db, notifications } from '@/db';
import { z } from 'zod';
import { verifySwarmRequest } from '@/lib/swarm/signature';
import { localHandleSchema, nodeDomainSchema } from '@/lib/utils/federation';
import { buildNotificationTarget } from '@/lib/notifications';
import { normalizeNodeDomain } from '@/lib/swarm/node-domain';
const swarmMentionSchema = z.object({
mentionedHandle: localHandleSchema,
@@ -21,6 +21,8 @@ const swarmMentionSchema = z.object({
actorDisplayName: z.string().min(1).max(50),
actorAvatarUrl: z.string().url().optional(),
actorNodeDomain: nodeDomainSchema,
actorDid: z.string().min(1).max(500).optional(),
actorPublicKey: z.string().min(1).max(5000).optional(),
postId: z.string().uuid(),
postContent: z.string().max(10000),
interactionId: z.string().uuid(),
@@ -29,6 +31,36 @@ const swarmMentionSchema = z.object({
signature: z.string(),
});
async function acceptsRemoteMention(
userId: string,
actorDomain: string,
cachedActorId: string | null,
): Promise<boolean> {
const nodeMute = await db.query.mutedNodes.findFirst({
where: { AND: [{ userId }, { nodeDomain: actorDomain }] },
columns: { id: true },
});
if (nodeMute) return false;
if (!cachedActorId) return true;
const [block, mute] = await Promise.all([
db.query.blocks.findFirst({
where: {
OR: [
{ AND: [{ userId }, { blockedUserId: cachedActorId }] },
{ AND: [{ userId: cachedActorId }, { blockedUserId: userId }] },
],
},
columns: { id: true },
}),
db.query.mutes.findFirst({
where: { AND: [{ userId }, { mutedUserId: cachedActorId }] },
columns: { id: true },
}),
]);
return !block && !mute;
}
/**
* POST /api/swarm/interactions/mention
*
@@ -65,39 +97,53 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: 'User not found' }, { status: 404 });
}
// Create notification with actor info stored directly
try {
await db.insert(notifications).values({
const actorDomain = normalizeNodeDomain(data.mention.actorNodeDomain);
const interactionKey = `mention:remote:${actorDomain}:${data.mention.interactionId}`;
const cachedActor = await db.query.users.findFirst({
where: { handle: `${data.mention.actorHandle.toLowerCase()}@${actorDomain}` },
columns: { id: true },
});
if (!(await acceptsRemoteMention(mentionedUser.id, actorDomain, cachedActor?.id || null))) {
// Do not disclose moderation state to the sending node.
return NextResponse.json({ success: true, message: 'Mention received' });
}
// The interaction ID is persisted under a unique index. A retried signed
// request therefore remains successful without creating duplicates.
const inserted = await db.insert(notifications).values({
userId: mentionedUser.id,
actorHandle: data.mention.actorHandle,
actorDisplayName: data.mention.actorDisplayName,
actorAvatarUrl: data.mention.actorAvatarUrl || null,
actorNodeDomain: data.mention.actorNodeDomain,
actorNodeDomain: actorDomain,
remotePostId: data.mention.postId,
remotePostDomain: actorDomain,
postContent: data.mention.postContent.slice(0, 200),
interactionId: interactionKey,
...(mentionedUser.isBot ? buildNotificationTarget(mentionedUser) : {}),
type: 'mention',
});
}).onConflictDoNothing().returning({ id: notifications.id });
if (inserted.length > 0) {
console.log(`[Swarm] Created mention notification for @${data.mentionedHandle} from ${data.mention.actorHandle}@${data.mention.actorNodeDomain}`);
} catch (notifError) {
console.error(`[Swarm] Failed to create mention notification:`, notifError);
}
// Also notify bot owner if this is a bot being mentioned
if (mentionedUser.isBot && mentionedUser.botOwnerId) {
try {
if (mentionedUser.isBot
&& mentionedUser.botOwnerId
&& await acceptsRemoteMention(mentionedUser.botOwnerId, actorDomain, cachedActor?.id || null)) {
await db.insert(notifications).values({
userId: mentionedUser.botOwnerId,
actorHandle: data.mention.actorHandle,
actorDisplayName: data.mention.actorDisplayName,
actorAvatarUrl: data.mention.actorAvatarUrl || null,
actorNodeDomain: data.mention.actorNodeDomain,
actorNodeDomain: actorDomain,
remotePostId: data.mention.postId,
remotePostDomain: actorDomain,
postContent: data.mention.postContent.slice(0, 200),
interactionId: `${interactionKey}:owner:${mentionedUser.id}`,
...buildNotificationTarget(mentionedUser),
type: 'mention',
});
} catch (err) {
console.error('[Swarm] Failed to notify bot owner:', err);
}
}).onConflictDoNothing();
}
console.log(`[Swarm] Received mention from ${data.mention.actorHandle}@${data.mention.actorNodeDomain} for @${data.mentionedHandle}`);
+40
View File
@@ -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 });
}
+113
View File
@@ -385,6 +385,23 @@ a.btn-primary:visited {
word-wrap: break-word;
}
.post-content a,
.post-content .mention-link {
position: relative;
z-index: 2;
}
.mention-link {
color: var(--accent);
font-weight: 550;
text-decoration: none;
}
.mention-link:hover,
.mention-link:focus-visible {
text-decoration: underline;
}
.repost-event-header {
display: flex;
align-items: center;
@@ -759,6 +776,7 @@ a.btn-primary:visited {
.compose {
padding: 16px;
border-bottom: 1px solid var(--border);
position: relative;
}
.compose-input {
@@ -780,6 +798,101 @@ a.btn-primary:visited {
color: var(--foreground-tertiary);
}
.compose-mention-field {
position: relative;
}
.compose-mention-suggestions {
position: absolute;
z-index: 40;
top: calc(100% + 4px);
left: 8px;
right: 8px;
max-height: 288px;
overflow-y: auto;
padding: 6px;
border: 1px solid var(--border);
border-radius: var(--radius-md);
background: var(--background);
box-shadow: 0 16px 40px rgba(0, 0, 0, 0.28);
}
.compose-mention-suggestion {
width: 100%;
display: flex;
align-items: center;
gap: 10px;
padding: 9px 10px;
border: 0;
border-radius: var(--radius-sm);
background: transparent;
color: var(--foreground);
text-align: left;
cursor: pointer;
}
.compose-mention-suggestion:hover,
.compose-mention-suggestion.selected {
background: var(--background-secondary);
}
.compose-mention-avatar {
width: 34px;
height: 34px;
flex: 0 0 34px;
overflow: hidden;
border-radius: 50%;
}
.compose-mention-avatar img,
.compose-mention-avatar .avatar-fallback {
width: 100%;
height: 100%;
}
.compose-mention-identity {
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.compose-mention-name,
.compose-mention-handle {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.compose-mention-name {
display: flex;
align-items: center;
gap: 7px;
font-size: 14px;
font-weight: 600;
}
.compose-mention-handle,
.compose-mention-loading {
color: var(--foreground-tertiary);
font-size: 13px;
}
.compose-mention-bot {
padding: 1px 5px;
border: 1px solid var(--border);
border-radius: 999px;
color: var(--foreground-secondary);
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.compose-mention-loading {
padding: 12px;
}
.compose-footer {
display: flex;
justify-content: space-between;
+7 -3
View File
@@ -1,10 +1,12 @@
'use client';
import { useEffect, useRef, TextareaHTMLAttributes } from 'react';
import { forwardRef, useEffect, useImperativeHandle, useRef, TextareaHTMLAttributes } from 'react';
export default function AutoTextarea(props: TextareaHTMLAttributes<HTMLTextAreaElement>) {
const AutoTextarea = forwardRef<HTMLTextAreaElement, TextareaHTMLAttributes<HTMLTextAreaElement>>(function AutoTextarea(props, forwardedRef) {
const textareaRef = useRef<HTMLTextAreaElement>(null);
useImperativeHandle(forwardedRef, () => textareaRef.current as HTMLTextAreaElement, []);
const adjustHeight = () => {
const textarea = textareaRef.current;
if (textarea) {
@@ -35,4 +37,6 @@ export default function AutoTextarea(props: TextareaHTMLAttributes<HTMLTextAreaE
}}
/>
);
}
});
export default AutoTextarea;
+174 -13
View File
@@ -1,6 +1,6 @@
'use client';
import { useState, useEffect, useRef } from 'react';
import { useState, useEffect, useId, useRef } from 'react';
import AutoTextarea from '@/components/AutoTextarea';
import { Post, Attachment } from '@/lib/types';
import { AlertTriangle, Music2, Paperclip } from 'lucide-react';
@@ -10,14 +10,31 @@ import { useAuth } from '@/lib/contexts/AuthContext';
import { StorageConfigurationPrompt } from '@/components/StorageConfigurationPrompt';
import { getStorageProvider, MediaUploadError, uploadMediaFile } from '@/lib/stuffbox/browser-upload';
import { getMediaKind } from '@/lib/media/upload-policy';
import { AvatarImage } from '@/components/AvatarImage';
import type { LinkPreviewData } from '@/lib/media/linkPreview';
import {
getActiveMentionQuery,
parseMentions,
replaceMentionQuery,
type ActiveMentionQuery,
} from '@/lib/mentions/parser';
interface MediaAttachment extends Attachment {
mimeType?: string;
filename?: string;
}
interface MentionSuggestion {
handle: string;
displayName: string | null;
avatarUrl: string | null;
isBot: boolean;
isRemote: boolean;
nodeDomain: string | null;
}
interface ComposeProps {
onPost: (content: string, mediaIds: string[], linkPreview?: any, replyToId?: string, isNsfw?: boolean) => void | boolean | Promise<void | boolean>;
onPost: (content: string, mediaIds: string[], linkPreview?: LinkPreviewData, replyToId?: string, isNsfw?: boolean) => void | boolean | Promise<void | boolean>;
onPosted?: () => void;
replyingTo?: Post | null;
onCancelReply?: () => void;
@@ -28,7 +45,7 @@ interface ComposeProps {
export function Compose({ onPost, onPosted, replyingTo, onCancelReply, placeholder = "What's happening?", isReply, autoFocus = false }: ComposeProps) {
const { isIdentityUnlocked } = useAuth();
const replyToHandle = replyingTo ? useFormattedHandle(replyingTo.author.handle) : '';
const replyToHandle = useFormattedHandle(replyingTo?.author.handle || '');
const [content, setContent] = useState('');
const [isPosting, setIsPosting] = useState(false);
const [attachments, setAttachments] = useState<MediaAttachment[]>([]);
@@ -38,13 +55,18 @@ export function Compose({ onPost, onPosted, replyingTo, onCancelReply, placehold
const [storageNotice, setStorageNotice] = useState<string | null>(null);
const [pendingStorageFiles, setPendingStorageFiles] = useState<File[]>([]);
const [showStorageConfiguration, setShowStorageConfiguration] = useState(false);
const [linkPreview, setLinkPreview] = useState<any>(null);
const [fetchingPreview, setFetchingPreview] = useState(false);
const [linkPreview, setLinkPreview] = useState<LinkPreviewData | null>(null);
const [lastDetectedUrl, setLastDetectedUrl] = useState<string | null>(null);
const [isNsfw, setIsNsfw] = useState(false);
const [canPostNsfw, setCanPostNsfw] = useState(false);
const [isNsfwNode, setIsNsfwNode] = useState(false);
const mediaInputRef = useRef<HTMLInputElement>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const mentionListId = useId();
const [activeMention, setActiveMention] = useState<ActiveMentionQuery | null>(null);
const [mentionSuggestions, setMentionSuggestions] = useState<MentionSuggestion[]>([]);
const [mentionLoading, setMentionLoading] = useState(false);
const [selectedMentionIndex, setSelectedMentionIndex] = useState(0);
const maxLength = 600;
const remaining = maxLength - content.length;
const canSubmit = content.trim().length > 0 || attachments.length > 0;
@@ -80,10 +102,16 @@ export function Compose({ onPost, onPosted, replyingTo, onCancelReply, placehold
// Detect URLs in content
useEffect(() => {
const urlRegex = /(?:https?:\/\/)?((?:[a-zA-Z0-9-]+\.)+[a-z]{2,63})\b([-a-zA-Z0-9@:%_\+.~#?&//=]*)/gi;
const matches = content.match(urlRegex);
const mentionRanges = parseMentions(content);
const matches = Array.from(content.matchAll(urlRegex))
.filter((match) => {
const start = match.index || 0;
const end = start + match[0].length;
return !mentionRanges.some((mention) => start < mention.end && end > mention.start);
});
if (matches && matches[0]) {
const url = matches[0];
if (matches[0]) {
const url = matches[0][0];
if (url !== lastDetectedUrl) {
setLastDetectedUrl(url);
fetchPreview(url);
@@ -94,8 +122,83 @@ export function Compose({ onPost, onPosted, replyingTo, onCancelReply, placehold
}
}, [content, lastDetectedUrl]);
useEffect(() => {
if (!activeMention) {
setMentionSuggestions([]);
setMentionLoading(false);
return;
}
const controller = new AbortController();
const timeout = window.setTimeout(async () => {
setMentionLoading(true);
try {
const response = await fetch(
`/api/mentions/suggestions?q=${encodeURIComponent(activeMention.query)}&limit=8`,
{ signal: controller.signal, cache: 'no-store' },
);
if (!response.ok) {
setMentionSuggestions([]);
return;
}
const data = await response.json();
const suggestions = Array.isArray(data.suggestions) ? data.suggestions : [];
setMentionSuggestions(suggestions);
setSelectedMentionIndex(0);
} catch (error) {
if (!(error instanceof DOMException && error.name === 'AbortError')) {
console.warn('[Compose] Mention suggestions failed:', error);
}
} finally {
if (!controller.signal.aborted) setMentionLoading(false);
}
}, 160);
return () => {
window.clearTimeout(timeout);
controller.abort();
};
}, [activeMention]);
const syncActiveMention = (value: string, caret: number | null) => {
setActiveMention(getActiveMentionQuery(value, caret ?? value.length));
};
const chooseMention = (suggestion: MentionSuggestion) => {
if (!activeMention) return;
const replacement = replaceMentionQuery(content, activeMention, suggestion.handle);
setContent(replacement.content);
setActiveMention(null);
setMentionSuggestions([]);
requestAnimationFrame(() => {
textareaRef.current?.focus();
textareaRef.current?.setSelectionRange(replacement.caret, replacement.caret);
});
};
const handleMentionKeyDown = (event: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (!activeMention || mentionSuggestions.length === 0) {
if (event.key === 'Escape') setActiveMention(null);
return;
}
if (event.key === 'ArrowDown') {
event.preventDefault();
setSelectedMentionIndex((index) => (index + 1) % mentionSuggestions.length);
} else if (event.key === 'ArrowUp') {
event.preventDefault();
setSelectedMentionIndex((index) => (index - 1 + mentionSuggestions.length) % mentionSuggestions.length);
} else if (event.key === 'Enter' || event.key === 'Tab') {
event.preventDefault();
chooseMention(mentionSuggestions[selectedMentionIndex]);
} else if (event.key === 'Escape') {
event.preventDefault();
setActiveMention(null);
setMentionSuggestions([]);
}
};
const fetchPreview = async (url: string) => {
setFetchingPreview(true);
try {
const res = await fetch(`/api/media/preview?url=${encodeURIComponent(url)}`);
if (res.ok) {
@@ -104,8 +207,6 @@ export function Compose({ onPost, onPosted, replyingTo, onCancelReply, placehold
}
} catch (err) {
console.error('Preview error', err);
} finally {
setFetchingPreview(false);
}
};
@@ -120,7 +221,7 @@ export function Compose({ onPost, onPosted, replyingTo, onCancelReply, placehold
setIsPosting(true);
try {
const posted = await onPost(content, attachments.map((item) => item.id).filter(Boolean), linkPreview, replyingTo?.id, isNsfw);
const posted = await onPost(content, attachments.map((item) => item.id).filter(Boolean), linkPreview || undefined, replyingTo?.id, isNsfw);
if (posted === false) {
setIsPosting(false);
return;
@@ -236,14 +337,74 @@ export function Compose({ onPost, onPosted, replyingTo, onCancelReply, placehold
</button>
</div>
)}
<div className="compose-mention-field">
<AutoTextarea
ref={textareaRef}
className="compose-input"
placeholder={placeholder}
value={content}
onChange={(e) => setContent(e.target.value)}
onChange={(event) => {
setContent(event.target.value);
syncActiveMention(event.target.value, event.target.selectionStart);
}}
onClick={(event) => syncActiveMention(event.currentTarget.value, event.currentTarget.selectionStart)}
onKeyUp={(event) => {
if (!['ArrowDown', 'ArrowUp', 'Enter', 'Tab', 'Escape'].includes(event.key)) {
syncActiveMention(event.currentTarget.value, event.currentTarget.selectionStart);
}
}}
onKeyDown={handleMentionKeyDown}
onBlur={() => window.setTimeout(() => setActiveMention(null), 120)}
maxLength={maxLength + 50} // Allow some overflow for better UX
autoFocus={autoFocus}
role="combobox"
aria-autocomplete="list"
aria-expanded={Boolean(activeMention && (mentionLoading || mentionSuggestions.length > 0))}
aria-controls={mentionListId}
aria-activedescendant={mentionSuggestions[selectedMentionIndex]
? `${mentionListId}-option-${selectedMentionIndex}`
: undefined}
/>
{activeMention && (mentionLoading || mentionSuggestions.length > 0) && (
<div
id={mentionListId}
className="compose-mention-suggestions"
role="listbox"
aria-label="Mention suggestions"
>
{mentionLoading && mentionSuggestions.length === 0 ? (
<div className="compose-mention-loading">Finding people</div>
) : mentionSuggestions.map((suggestion, index) => (
<button
id={`${mentionListId}-option-${index}`}
type="button"
role="option"
aria-selected={index === selectedMentionIndex}
className={`compose-mention-suggestion ${index === selectedMentionIndex ? 'selected' : ''}`}
key={suggestion.handle}
onMouseDown={(event) => event.preventDefault()}
onMouseEnter={() => setSelectedMentionIndex(index)}
onClick={() => chooseMention(suggestion)}
>
<span className="compose-mention-avatar">
<AvatarImage
avatarUrl={suggestion.avatarUrl}
seed={suggestion.handle}
alt=""
/>
</span>
<span className="compose-mention-identity">
<span className="compose-mention-name">
{suggestion.displayName || suggestion.handle.split('@')[0]}
{suggestion.isBot && <span className="compose-mention-bot">Bot</span>}
</span>
<span className="compose-mention-handle">@{suggestion.handle}</span>
</span>
</button>
))}
</div>
)}
</div>
{attachments.length > 0 && (
<div className="compose-media-grid">
{attachments.map((item) => {
+25 -15
View File
@@ -11,13 +11,14 @@ import { useToast } from '@/lib/contexts/ToastContext';
import { VideoEmbed } from '@/components/VideoEmbed';
import BlurredImage from '@/components/BlurredImage';
import BlurredVideo from '@/components/BlurredVideo';
import { useFormattedHandle } from '@/lib/utils/handle';
import { getProfilePath, useFormattedHandle } from '@/lib/utils/handle';
import { useDomain } from '@/lib/contexts/ConfigContext';
import { signedAPI } from '@/lib/api/signed-fetch';
import type { LinkPreviewData } from '@/lib/media/linkPreview';
import { AvatarImage } from '@/components/AvatarImage';
import { AudioPlayer } from '@/components/AudioPlayer';
import { getMediaKind } from '@/lib/media/upload-policy';
import { tokenizePostContent } from '@/lib/mentions/parser';
// Component for link preview image that hides on error
function LinkPreviewImage({ src, alt }: { src: string; alt: string }) {
@@ -584,9 +585,24 @@ export function PostCard({ post, onLike, onRepost, onComment, onDelete, onHide,
const renderContent = (content: string, hidePreviewUrl?: string) => {
const decoded = decodeHtmlEntities(content);
const parts = decoded.split(/(https?:\/\/[^\s]+)/g);
return parts.map((part, index) => {
if (part.match(/^https?:\/\/[^\s]+$/)) {
const tokens = tokenizePostContent(decoded, domain);
return tokens.map((token, index) => {
if (token.type === 'mention') {
return (
<Link
key={`mention-${token.start}-${token.end}`}
href={getProfilePath(token.canonicalHandle)}
className="mention-link"
onClick={(event) => event.stopPropagation()}
title={token.isQualified ? token.raw : `@${token.handle}@${domain}`}
>
{token.raw}
</Link>
);
}
if (token.type === 'url') {
const part = token.value;
// If this URL matches the link preview URL, hide it entirely
if (hidePreviewUrl && part.includes(hidePreviewUrl.replace(/^https?:\/\/(www\.)?/, '').split('/')[0])) {
return null;
@@ -622,16 +638,7 @@ export function PostCard({ post, onLike, onRepost, onComment, onDelete, onHide,
);
}
}
// Handle newlines
if (part.includes('\n')) {
return part.split('\n').map((line, lineIndex, arr) => (
<span key={`text-${index}-${lineIndex}`}>
{line}
{lineIndex < arr.length - 1 && <br />}
</span>
));
}
return <span key={`text-${index}`}>{part}</span>;
return <span key={`text-${token.start}-${index}`}>{token.value}</span>;
});
};
@@ -651,7 +658,10 @@ export function PostCard({ post, onLike, onRepost, onComment, onDelete, onHide,
? JSON.parse(post.swarmReplyToAuthor)
: post.swarmReplyToAuthor)?.nodeDomain,
} as Post : null);
const replyToHandle = effectiveReplyTo?.author?.handle ? useFormattedHandle(effectiveReplyTo.author.handle, effectiveReplyTo.nodeDomain) : '';
const replyToHandle = useFormattedHandle(
effectiveReplyTo?.author?.handle || '',
effectiveReplyTo?.nodeDomain,
);
const repostHandle = useFormattedHandle(post.author.handle, post.nodeDomain);
const hasOwnContent = decodeHtmlEntities(post.content).trim().length > 0;
const isRepostEvent = Boolean(post.repostOf);
+8
View File
@@ -56,6 +56,7 @@ export const relations = defineRelations(schema, (r) => ({
}),
likes: r.many.likes({ from: r.posts.id, to: r.likes.postId }),
media: r.many.media({ from: r.posts.id, to: r.media.postId }),
mentionDeliveries: r.many.mentionDeliveries({ from: r.posts.id, to: r.mentionDeliveries.postId }),
},
media: {
user: r.one.users({ from: r.media.userId, to: r.users.id, optional: false }),
@@ -101,6 +102,13 @@ export const relations = defineRelations(schema, (r) => ({
}),
post: r.one.posts({ from: r.notifications.postId, to: r.posts.id }),
},
mentionDeliveries: {
post: r.one.posts({
from: r.mentionDeliveries.postId,
to: r.posts.id,
optional: false,
}),
},
sessions: {
user: r.one.users({ from: r.sessions.userId, to: r.users.id, optional: false }),
},
+29
View File
@@ -409,13 +409,41 @@ export const notifications = sqliteTable('notifications', {
targetIsBot: integer('target_is_bot', { mode: 'boolean' }),
// Post reference
postId: text('post_id').references(() => posts.id, { onDelete: 'cascade' }),
remotePostId: text('remote_post_id'),
remotePostDomain: text('remote_post_domain'),
postContent: text('post_content'), // Cached content for display
interactionId: text('interaction_id'), // Idempotency key for local and federated interactions
type: text('type').notNull(), // follow | like | repost | mention
readAt: integer('read_at', { mode: 'timestamp' }),
createdAt: integer('created_at', { mode: 'timestamp' }).default(currentTimestamp).notNull(),
}, (table) => [
index('notifications_user_idx').on(table.userId),
index('notifications_created_idx').on(table.createdAt),
uniqueIndex('notifications_interaction_unique_idx').on(table.interactionId),
]);
// ============================================
// MENTION DELIVERY OUTBOX
// ============================================
export const mentionDeliveries = sqliteTable('mention_deliveries', {
id: text('id').primaryKey().$defaultFn(() => randomUUID()),
interactionId: text('interaction_id').notNull().unique(),
postId: text('post_id').notNull().references(() => posts.id, { onDelete: 'cascade' }),
targetHandle: text('target_handle').notNull(),
targetDomain: text('target_domain').notNull(),
status: text('status').default('pending').notNull(), // pending | processing | retry | delivered | dead
attempts: integer('attempts').default(0).notNull(),
nextAttemptAt: integer('next_attempt_at', { mode: 'timestamp' }).default(currentTimestamp).notNull(),
lastAttemptAt: integer('last_attempt_at', { mode: 'timestamp' }),
deliveredAt: integer('delivered_at', { mode: 'timestamp' }),
lastError: text('last_error'),
createdAt: integer('created_at', { mode: 'timestamp' }).default(currentTimestamp).notNull(),
updatedAt: integer('updated_at', { mode: 'timestamp' }).default(currentTimestamp).notNull(),
}, (table) => [
uniqueIndex('mention_deliveries_target_unique_idx').on(table.postId, table.targetHandle, table.targetDomain),
index('mention_deliveries_due_idx').on(table.status, table.nextAttemptAt),
]);
@@ -633,6 +661,7 @@ export const botMentions = sqliteTable('bot_mentions', {
index('bot_mentions_bot_idx').on(table.botId),
index('bot_mentions_processed_idx').on(table.isProcessed),
index('bot_mentions_created_idx').on(table.createdAt),
uniqueIndex('bot_mentions_bot_post_unique_idx').on(table.botId, table.postId),
]);
+20
View File
@@ -14,10 +14,13 @@ import { announceToSeeds } from '@/lib/swarm/discovery';
import { getSwarmStats } from '@/lib/swarm/registry';
import { syncRemoteFollowsPosts } from '@/lib/background/remote-sync';
import { isPublicSwarmDomain } from '@/lib/swarm/node-domain';
import { processMentionDeliveryOutbox } from '@/lib/mentions/delivery';
import { processAllActiveBotMentions } from '@/lib/bots/mentionHandler';
const BOT_INTERVAL_MS = 60 * 1000; // 1 minute
const GOSSIP_INTERVAL_MS = 5 * 60 * 1000; // 5 minutes
const REMOTE_SYNC_INTERVAL_MS = 60 * 1000; // 1 minute - keep feeds fresh
const MENTION_DELIVERY_INTERVAL_MS = 30 * 1000;
const STARTUP_DELAY_MS = 10 * 1000; // Wait 10s for server to be ready
let isStarted = false;
@@ -33,6 +36,7 @@ function log(category: string, message: string, data?: unknown) {
async function runBotTasks() {
try {
const mentions = await processAllActiveBotMentions();
const results = await processAllAutonomousBots();
const posted = results.filter(r => r.result.posted).length;
@@ -58,11 +62,25 @@ async function runBotTasks() {
const errorMsgs = results.filter(r => r.error).map(r => `${r.botHandle}: ${r.error}`);
log('BOTS', `Errors: ${errorMsgs.join('; ')}`);
}
if (mentions.detected > 0 || mentions.responded > 0 || mentions.failed > 0) {
log('BOTS', `Mentions: ${mentions.detected} detected, ${mentions.responded} responded, ${mentions.failed} failed`);
}
} catch (error) {
log('BOTS', `Error: ${error}`);
}
}
async function runMentionDeliveries() {
try {
const result = await processMentionDeliveryOutbox();
if (result.delivered > 0 || result.retried > 0 || result.dead > 0) {
log('MENTIONS', `Delivered ${result.delivered}, retrying ${result.retried}, dead-lettered ${result.dead}`);
}
} catch (error) {
log('MENTIONS', `Outbox error: ${error}`);
}
}
async function runSwarmGossip() {
try {
const stats = await getSwarmStats();
@@ -140,12 +158,14 @@ export function startBackgroundTasks(origin?: string) {
// Run initial bot check
await runBotTasks();
await runMentionDeliveries();
// Run initial remote sync (after 15s to let server stabilize)
setTimeout(() => runRemoteSync(syncOrigin), 15 * 1000);
// Schedule recurring tasks
setInterval(runBotTasks, BOT_INTERVAL_MS);
setInterval(runMentionDeliveries, MENTION_DELIVERY_INTERVAL_MS);
if (publicSwarmEnabled) {
setInterval(runSwarmGossip, GOSSIP_INTERVAL_MS);
}
+204
View File
@@ -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();
});
});
+5 -1
View File
@@ -226,9 +226,13 @@ vi.mock('@/db', () => {
responsePostId: null,
};
mentionsStore.set(id, mention);
return {
const result = {
returning: vi.fn().mockResolvedValue([mention]),
};
return {
...result,
onConflictDoNothing: vi.fn().mockReturnValue(result),
};
} else {
// This is a post insert
const id = `post-${++postIdCounter}`;
+206 -27
View File
@@ -7,11 +7,16 @@
* Requirements: 7.1, 7.2, 7.3, 7.4, 7.5, 7.6
*/
import { db, bots, botMentions, posts, users } from '@/db';
import { eq, and, desc, asc, isNull } from 'drizzle-orm';
import { db, botMentions, notifications, posts, users } from '@/db';
import { and, eq, isNull, lte, or, sql } from 'drizzle-orm';
import { ContentGenerator, type Bot as GeneratorBot, type Post as GeneratorPost } from './contentGenerator';
import { canReply, recordReply } from './rateLimiter';
import { decryptApiKey } from './encryption';
import type { LLMProvider } from './encryption';
import { parseMentions, uniqueMentions } from '@/lib/mentions/parser';
import { registerPostMentions } from '@/lib/mentions/delivery';
import { deliverPostToSwarmFollowers } from '@/lib/swarm/interactions';
const MENTION_PROCESSING_LEASE_MS = 10 * 60 * 1000;
// ============================================
// TYPES
@@ -50,6 +55,18 @@ export interface PostWithAuthor {
};
}
interface ConversationPostRow {
id: string;
userId: string;
content: string;
replyToId: string | null;
createdAt: Date;
author: {
handle: string;
displayName: string | null;
};
}
/**
* Mention detection result.
*/
@@ -109,7 +126,7 @@ export async function detectMentions(botId: string): Promise<MentionDetectionRes
where: { id: botId },
with: {
user: {
columns: { handle: true },
columns: { id: true, handle: true },
},
},
columns: { id: true },
@@ -130,15 +147,8 @@ export async function detectMentions(botId: string): Promise<MentionDetectionRes
const existingPostIds = new Set(existingMentions.map(m => m.postId));
// Find posts that mention the bot's handle
// Note: In a production system, this would be more efficient with full-text search
// or a dedicated mentions table updated on post creation
const mentionPattern = `@${bot.user.handle}`;
// Get recent posts (last 24 hours) that might contain mentions
const oneDayAgo = new Date();
oneDayAgo.setDate(oneDayAgo.getDate() - 1);
// Direct post creation records bot mentions immediately. This bounded scan
// remains as a repair path for imported posts and older nodes.
const recentPosts = await db.query.posts.findMany({
where: { AND: [{ isRemoved: false }] },
with: {
@@ -155,9 +165,12 @@ export async function detectMentions(botId: string): Promise<MentionDetectionRes
});
// Filter posts that mention the bot and aren't already tracked
const newMentionPosts = recentPosts.filter(post =>
post.content.includes(mentionPattern) &&
!existingPostIds.has(post.id)
const newMentionPosts = recentPosts.filter((post) =>
post.userId !== bot.user.id
&& !existingPostIds.has(post.id)
&& uniqueMentions(parseMentions(post.content)).some(
(mention) => mention.isLocal && mention.handle === bot.user.handle.toLowerCase(),
)
);
// Create mention records
@@ -171,7 +184,9 @@ export async function detectMentions(botId: string): Promise<MentionDetectionRes
content: post.content,
isProcessed: false,
isRemote: false, // Local mentions for now
}).returning();
}).onConflictDoNothing().returning();
if (!mention) continue;
newMentions.push({
id: mention.id,
@@ -301,7 +316,7 @@ export async function getConversationContext(
let depth = 0;
while (currentPostId && depth < maxDepth) {
const post: any = await db.query.posts.findFirst({
const post = await db.query.posts.findFirst({
where: { id: currentPostId },
with: {
author: {
@@ -311,7 +326,7 @@ export async function getConversationContext(
},
},
},
});
}) as ConversationPostRow | undefined;
if (!post) break;
@@ -357,6 +372,8 @@ export async function getConversationContext(
* Validates: Requirements 7.3, 7.4, 7.6
*/
export async function processMention(mentionId: string): Promise<MentionResponseResult> {
let ownsLease = false;
let completed = false;
try {
// Get the mention
const mention = await db.query.botMentions.findFirst({
@@ -370,13 +387,45 @@ export async function processMention(mentionId: string): Promise<MentionResponse
);
}
// Check if already processed
// A completed response is idempotent. A stale claim is released so a
// process crash cannot strand a mention forever.
if (mention.isProcessed) {
if (!mention.responsePostId) {
const staleBefore = new Date(Date.now() - MENTION_PROCESSING_LEASE_MS);
const isStale = !mention.processedAt || mention.processedAt <= staleBefore;
if (!isStale) {
return { success: false, error: 'Mention is already being processed' };
}
await db.update(botMentions).set({
isProcessed: false,
processedAt: null,
}).where(and(
eq(botMentions.id, mentionId),
eq(botMentions.isProcessed, true),
isNull(botMentions.responsePostId),
));
} else {
return {
success: true,
responsePostId: mention.responsePostId || undefined,
responsePostId: mention.responsePostId,
};
}
}
const claimed = await db.update(botMentions).set({
isProcessed: true,
processedAt: new Date(),
}).where(and(
eq(botMentions.id, mentionId),
eq(botMentions.isProcessed, false),
)).returning({ id: botMentions.id });
if (claimed.length === 0) {
return {
success: false,
error: 'Mention is already being processed',
};
}
ownsLease = true;
// Check rate limits (Requirement 7.6)
const rateLimitCheck = await canReply(mention.botId);
@@ -395,6 +444,11 @@ export async function processMention(mentionId: string): Promise<MentionResponse
columns: {
id: true,
handle: true,
displayName: true,
avatarUrl: true,
isNsfw: true,
did: true,
publicKey: true,
},
},
},
@@ -439,7 +493,7 @@ export async function processMention(mentionId: string): Promise<MentionResponse
name: bot.name,
handle: bot.user.handle,
personalityConfig: JSON.parse(bot.personalityConfig),
llmProvider: bot.llmProvider as any,
llmProvider: bot.llmProvider as LLMProvider,
llmModel: bot.llmModel,
llmEndpoint: bot.llmEndpoint,
llmApiKeyEncrypted: bot.llmApiKeyEncrypted,
@@ -465,29 +519,118 @@ export async function processMention(mentionId: string): Promise<MentionResponse
);
// Create response post
const [responsePost] = await db.insert(posts).values({
const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:43821';
const postUuid = mention.id;
const responsePost = await db.transaction(async (tx) => {
const [createdPost] = await tx.insert(posts).values({
id: postUuid,
userId: bot.user.id, // Bot posts as its associated user
content: generatedReply.text,
replyToId: mention.postId,
}).returning();
isNsfw: bot.user.isNsfw,
apId: `https://${nodeDomain}/posts/${postUuid}`,
apUrl: `https://${nodeDomain}/posts/${postUuid}`,
}).onConflictDoNothing().returning();
const [existingPost] = createdPost ? [] : await tx.select().from(posts)
.where(eq(posts.id, postUuid))
.limit(1);
const reply = createdPost || existingPost;
if (!reply || reply.userId !== bot.user.id || reply.replyToId !== mention.postId) {
throw new MentionHandlerError('Unable to persist bot reply', 'DATABASE_ERROR');
}
// Mark mention as processed
await db.update(botMentions)
if (!createdPost) {
await tx.update(botMentions).set({
isProcessed: true,
processedAt: new Date(),
responsePostId: reply.id,
}).where(eq(botMentions.id, mentionId));
return reply;
}
await tx.update(users)
.set({ postsCount: sql`${users.postsCount} + 1` })
.where(eq(users.id, bot.user.id));
await tx.update(posts)
.set({ repliesCount: sql`${posts.repliesCount} + 1` })
.where(eq(posts.id, mention.postId));
await tx.insert(notifications).values({
userId: mention.authorId,
actorId: bot.user.id,
actorHandle: bot.user.handle,
actorDisplayName: bot.user.displayName,
actorAvatarUrl: bot.user.avatarUrl,
actorNodeDomain: null,
postId: reply.id,
postContent: reply.content.slice(0, 200),
interactionId: `bot-reply:${mention.id}`,
type: 'reply',
}).onConflictDoNothing();
await tx.update(botMentions)
.set({
isProcessed: true,
processedAt: new Date(),
responsePostId: responsePost.id,
responsePostId: reply.id,
})
.where(eq(botMentions.id, mentionId));
return reply;
});
completed = true;
try {
await registerPostMentions({
postId: responsePost.id,
content: responsePost.content,
actor: {
id: bot.user.id,
handle: bot.user.handle,
displayName: bot.user.displayName,
avatarUrl: bot.user.avatarUrl,
did: bot.user.did,
publicKey: bot.user.publicKey,
},
nodeDomain,
});
} catch (error) {
console.error('[Bots] Failed to register mentions in bot reply:', error);
}
// Record reply for rate limiting
try {
await recordReply(mention.botId);
} catch (error) {
console.error('[Bots] Failed to record mention reply rate limit:', error);
}
void deliverPostToSwarmFollowers(
bot.user.id,
responsePost,
{
handle: bot.user.handle,
displayName: bot.user.displayName,
avatarUrl: bot.user.avatarUrl,
isNsfw: bot.user.isNsfw,
},
[],
nodeDomain,
).catch((error) => console.error('[Bots] Failed to federate mention reply:', error));
return {
success: true,
responsePostId: responsePost.id,
};
} catch (error) {
if (ownsLease && !completed) {
await db.update(botMentions).set({
isProcessed: false,
processedAt: null,
}).where(and(
eq(botMentions.id, mentionId),
isNull(botMentions.responsePostId),
)).catch((releaseError) => {
console.error(`[Bots] Failed to release mention claim ${mentionId}:`, releaseError);
});
}
if (error instanceof MentionHandlerError) {
return {
success: false,
@@ -539,6 +682,42 @@ export async function processAllMentions(botId: string): Promise<MentionResponse
return results;
}
/** Detect and process pending mentions for every active bot. */
export async function processAllActiveBotMentions(): Promise<{
bots: number;
detected: number;
responded: number;
failed: number;
}> {
const staleBefore = new Date(Date.now() - MENTION_PROCESSING_LEASE_MS);
await db.update(botMentions).set({ isProcessed: false, processedAt: null })
.where(and(
eq(botMentions.isProcessed, true),
isNull(botMentions.responsePostId),
or(isNull(botMentions.processedAt), lte(botMentions.processedAt, staleBefore)),
));
const activeBots = await db.query.bots.findMany({
where: { AND: [{ isActive: true }, { isSuspended: false }] },
columns: { id: true },
});
const totals = { bots: activeBots.length, detected: 0, responded: 0, failed: 0 };
for (const bot of activeBots) {
try {
const detected = await detectMentions(bot.id);
totals.detected += detected.mentions.length;
const responses = await processAllMentions(bot.id);
totals.responded += responses.filter((response) => response.success).length;
totals.failed += responses.filter((response) => !response.success).length;
} catch (error) {
totals.failed += 1;
console.error(`[Bots] Mention processing failed for ${bot.id}:`, error);
}
}
return totals;
}
// ============================================
// MENTION STORAGE
// ============================================
+12
View File
@@ -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);
});
});
+318
View File
@@ -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;
}
}
+95
View File
@@ -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' },
]);
});
});
+216
View File
@@ -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;
}
+14
View File
@@ -52,4 +52,18 @@ describe('browser notification presentation', () => {
url: '/notifications',
});
});
it('links federated mentions to the source swarm post', () => {
expect(getBrowserNotificationContent({
id: 'notification-remote',
type: 'mention',
actor: { handle: 'alice@remote.example', displayName: 'Alice' },
post: {
id: 'swarm:remote.example:550e8400-e29b-41d4-a716-446655440000',
content: 'Hello @bob@local.example',
},
})).toMatchObject({
url: '/posts/swarm:remote.example:550e8400-e29b-41d4-a716-446655440000',
});
});
});
+19 -18
View File
@@ -18,6 +18,7 @@ import { filterBlockedDomains, isNodeBlocked, normalizeNodeDomain } from './node
import { getPublicSwarmDomain } from './node-domain';
import { safeFederationRequest } from './safe-federation-http';
import { serializeLinkPreviewMedia } from '@/lib/media/linkPreview';
import { parseMentions, uniqueMentions } from '@/lib/mentions/parser';
// ============================================
// TYPES
@@ -53,6 +54,8 @@ export interface SwarmInteractionResponse {
success: boolean;
message?: string;
error?: string;
statusCode?: number;
retryable?: boolean;
}
export interface SwarmLikePayload {
@@ -130,6 +133,8 @@ export interface SwarmMentionPayload {
actorDisplayName: string;
actorAvatarUrl?: string;
actorNodeDomain: string;
actorDid?: string;
actorPublicKey?: string;
postId: string;
postContent: string;
interactionId: string;
@@ -273,6 +278,8 @@ async function deliverSwarmInteraction(
if (await isNodeBlocked(normalizedTargetDomain)) {
return {
success: false,
statusCode: 403,
retryable: false,
error: `Blocked node: ${normalizedTargetDomain}`,
};
}
@@ -295,7 +302,9 @@ async function deliverSwarmInteraction(
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 10000); // 10s timeout
const response = await fetch(url, {
let response: Response;
try {
response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
@@ -304,13 +313,16 @@ async function deliverSwarmInteraction(
body: JSON.stringify(signedPayload),
signal: controller.signal,
});
} finally {
clearTimeout(timeout);
}
if (!response.ok) {
const errorText = await response.text().catch(() => 'Unknown error');
return {
success: false,
statusCode: response.status,
retryable: response.status === 408 || response.status === 429 || response.status >= 500,
error: `HTTP ${response.status}: ${errorText}`,
};
}
@@ -323,6 +335,7 @@ async function deliverSwarmInteraction(
} catch (error) {
return {
success: false,
retryable: true,
error: error instanceof Error ? error.message : 'Unknown error',
};
}
@@ -620,19 +633,7 @@ export async function fetchSwarmPost(
* Returns array of { handle, domain } for remote mentions
*/
export function extractMentions(content: string): { handle: string; domain: string | null }[] {
// Match @handle or @handle@domain patterns
const mentionRegex = /@([a-zA-Z0-9_]+)(?:@([a-zA-Z0-9.-]+))?/g;
const mentions: { handle: string; domain: string | null }[] = [];
let match;
while ((match = mentionRegex.exec(content)) !== null) {
mentions.push({
handle: match[1].toLowerCase(),
domain: match[2]?.toLowerCase() || null,
});
}
return mentions;
return parseMentions(content).map(({ handle, domain }) => ({ handle, domain }));
}
/**
@@ -648,13 +649,13 @@ export async function deliverSwarmMentions(
nodeDomain: string;
}
): Promise<{ delivered: number; failed: number }> {
const mentions = extractMentions(content);
const mentions = uniqueMentions(parseMentions(content, actor.nodeDomain));
let delivered = 0;
let failed = 0;
for (const mention of mentions) {
// Skip local mentions (no domain)
if (!mention.domain) continue;
// Local and same-node-qualified mentions are handled synchronously.
if (mention.isLocal || !mention.domain) continue;
// Check if it's a swarm node
const isSwarm = await isSwarmNode(mention.domain);
+4 -4
View File
@@ -1,16 +1,16 @@
import { z } from 'zod';
const localHandlePattern = /^[a-zA-Z0-9_]{3,20}$/;
const localHandlePattern = /^[a-zA-Z0-9_]{3,30}$/;
const hostnameLabel = '[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?';
const nodeDomainPattern = `(?:localhost|127\\.0\\.0\\.1|${hostnameLabel}(?:\\.${hostnameLabel})+)(?::\\d{1,5})?`;
const federatedHandlePattern = new RegExp(`^[a-zA-Z0-9_]{3,20}(?:@${nodeDomainPattern})?$`);
const federatedHandlePattern = new RegExp(`^[a-zA-Z0-9_]{3,30}(?:@${nodeDomainPattern})?$`);
const nodeDomainRegex = new RegExp(`^${nodeDomainPattern}$`);
export const localHandleSchema = z
.string()
.min(3)
.max(20)
.regex(localHandlePattern, 'Handle must be 3-20 characters, alphanumeric and underscores only');
.max(30)
.regex(localHandlePattern, 'Handle must be 3-30 characters, alphanumeric and underscores only');
export const federatedHandleSchema = z
.string()