mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-04-11 16:07:15 +02:00
refactor: extract shared utilities from chat adapters
Create reusable utility modules for chat adapters to reduce code duplication and improve testability: - event-fetcher.ts: fetchEvent(), fetchReplaceableEvent() with EventStore caching and timeout handling - relay-utils.ts: getOutboxRelays(), getInboxRelays(), mergeRelays(), collectOutboxRelays() for NIP-65 relay list resolution - message-utils.ts: zapReceiptToMessage(), nutzapToMessage(), eventToMessage(), and protocol-specific reply extractors (getNip10ReplyTo, getNip22ReplyTo, getQTagReplyTo) Refactored NIP-22 adapter to use shared utilities, reducing code from ~1165 lines to ~830 lines (~30% reduction). Other adapters (NIP-10, NIP-29, NIP-53) can be refactored similarly using the same pattern.
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { Observable, firstValueFrom, combineLatest, of } from "rxjs";
|
||||
import { map, first, toArray } from "rxjs/operators";
|
||||
import { map, toArray } from "rxjs/operators";
|
||||
import type { Filter } from "nostr-tools";
|
||||
import { nip19 } from "nostr-tools";
|
||||
import {
|
||||
@@ -20,15 +20,21 @@ import eventStore from "@/services/event-store";
|
||||
import pool from "@/services/relay-pool";
|
||||
import { publishEventToRelays } from "@/services/hub";
|
||||
import accountManager from "@/services/accounts";
|
||||
import { AGGREGATOR_RELAYS } from "@/services/loaders";
|
||||
import { normalizeURL, getTagValue } from "applesauce-core/helpers";
|
||||
import { getTagValue } from "applesauce-core/helpers";
|
||||
import { EventFactory } from "applesauce-core/event-factory";
|
||||
import { getArticleTitle } from "applesauce-common/helpers";
|
||||
import {
|
||||
getZapAmount,
|
||||
getZapSender,
|
||||
getZapRecipient,
|
||||
getArticleTitle,
|
||||
} from "applesauce-common/helpers";
|
||||
fetchEvent,
|
||||
fetchReplaceableEvent,
|
||||
getOutboxRelays,
|
||||
mergeRelays,
|
||||
zapReceiptToMessage,
|
||||
eventToMessage,
|
||||
getNip22ReplyTo,
|
||||
AGGREGATOR_RELAYS,
|
||||
} from "../utils";
|
||||
|
||||
const LOG_PREFIX = "[NIP-22]";
|
||||
|
||||
/**
|
||||
* NIP-22 Adapter - Comments on Non-Kind-1 Events
|
||||
@@ -49,9 +55,6 @@ export class Nip22Adapter extends ChatProtocolAdapter {
|
||||
|
||||
/**
|
||||
* Parse identifier - accepts nevent (non-kind-1) or naddr format
|
||||
* Examples:
|
||||
* - nevent1... (with kind != 1)
|
||||
* - naddr1... (addressable events like articles)
|
||||
*/
|
||||
parseIdentifier(input: string): ProtocolIdentifier | null {
|
||||
// Try nevent format
|
||||
@@ -62,7 +65,6 @@ export class Nip22Adapter extends ChatProtocolAdapter {
|
||||
const { id, relays, author, kind } = decoded.data;
|
||||
|
||||
// Only handle non-kind-1 events (kind 1 is handled by NIP-10)
|
||||
// If kind is unspecified, we'll check after fetching
|
||||
if (kind === 1) {
|
||||
return null;
|
||||
}
|
||||
@@ -85,10 +87,7 @@ export class Nip22Adapter extends ChatProtocolAdapter {
|
||||
if (decoded.type === "naddr") {
|
||||
const { kind, pubkey, identifier, relays } = decoded.data;
|
||||
|
||||
// Skip certain kinds handled by other adapters
|
||||
// 39000 = NIP-29 group metadata
|
||||
// 30311 = NIP-53 live activity
|
||||
// 10009 = Group list
|
||||
// Skip kinds handled by other adapters
|
||||
if (kind === 39000 || kind === 30311 || kind === 10009) {
|
||||
return null;
|
||||
}
|
||||
@@ -121,12 +120,9 @@ export class Nip22Adapter extends ChatProtocolAdapter {
|
||||
|
||||
const relayHints = identifier.relays || [];
|
||||
|
||||
// Determine if this is an event pointer or address pointer
|
||||
if ("id" in identifier.value) {
|
||||
// Event pointer
|
||||
return this.resolveFromEventPointer(identifier.value, relayHints);
|
||||
} else {
|
||||
// Address pointer
|
||||
return this.resolveFromAddressPointer(identifier.value, relayHints);
|
||||
}
|
||||
}
|
||||
@@ -138,39 +134,37 @@ export class Nip22Adapter extends ChatProtocolAdapter {
|
||||
pointer: { id: string; relays?: string[]; author?: string; kind?: number },
|
||||
relayHints: string[],
|
||||
): Promise<Conversation> {
|
||||
// 1. Fetch the provided event
|
||||
const providedEvent = await this.fetchEvent(
|
||||
pointer.id,
|
||||
pointer.relays || relayHints,
|
||||
);
|
||||
const providedEvent = await fetchEvent(pointer.id, {
|
||||
relayHints: pointer.relays || relayHints,
|
||||
logPrefix: LOG_PREFIX,
|
||||
});
|
||||
|
||||
if (!providedEvent) {
|
||||
throw new Error("Event not found");
|
||||
}
|
||||
|
||||
// 2. If this is a kind 1111 comment, resolve to the actual root
|
||||
// If kind 1111 comment, resolve to actual root
|
||||
if (providedEvent.kind === 1111) {
|
||||
return this.resolveFromComment(providedEvent, relayHints);
|
||||
}
|
||||
|
||||
// 3. If this is kind 1, reject (NIP-10 should handle it)
|
||||
// Kind 1 should use NIP-10
|
||||
if (providedEvent.kind === 1) {
|
||||
throw new Error("Kind 1 notes should use NIP-10 thread chat");
|
||||
}
|
||||
|
||||
// 4. This event IS the root - build conversation around it
|
||||
const conversationRelays = await this.getCommentRelays(
|
||||
providedEvent,
|
||||
// This event IS the root
|
||||
const conversationRelays = await this.buildRelays(
|
||||
providedEvent.pubkey,
|
||||
relayHints,
|
||||
);
|
||||
const title = this.extractTitle(providedEvent);
|
||||
const participants = this.extractParticipants(providedEvent);
|
||||
|
||||
return {
|
||||
id: `nip-22:${providedEvent.id}`,
|
||||
type: "group",
|
||||
protocol: "nip-22",
|
||||
title,
|
||||
participants,
|
||||
title: this.extractTitle(providedEvent),
|
||||
participants: this.extractParticipants(providedEvent),
|
||||
metadata: {
|
||||
rootEventId: providedEvent.id,
|
||||
providedEventId: providedEvent.id,
|
||||
@@ -189,18 +183,19 @@ export class Nip22Adapter extends ChatProtocolAdapter {
|
||||
pointer: { kind: number; pubkey: string; identifier: string },
|
||||
relayHints: string[],
|
||||
): Promise<Conversation> {
|
||||
// Fetch the replaceable event
|
||||
const rootEvent = await this.fetchReplaceableEvent(
|
||||
const rootEvent = await fetchReplaceableEvent(
|
||||
pointer.kind,
|
||||
pointer.pubkey,
|
||||
pointer.identifier,
|
||||
relayHints,
|
||||
{
|
||||
identifier: pointer.identifier,
|
||||
relayHints,
|
||||
logPrefix: LOG_PREFIX,
|
||||
},
|
||||
);
|
||||
|
||||
const coordinate = `${pointer.kind}:${pointer.pubkey}:${pointer.identifier}`;
|
||||
|
||||
if (!rootEvent) {
|
||||
// Even without the event, we can create a conversation based on the address
|
||||
return {
|
||||
id: `nip-22:${coordinate}`,
|
||||
type: "group",
|
||||
@@ -216,19 +211,17 @@ export class Nip22Adapter extends ChatProtocolAdapter {
|
||||
};
|
||||
}
|
||||
|
||||
const conversationRelays = await this.getCommentRelays(
|
||||
rootEvent,
|
||||
const conversationRelays = await this.buildRelays(
|
||||
rootEvent.pubkey,
|
||||
relayHints,
|
||||
);
|
||||
const title = this.extractTitle(rootEvent);
|
||||
const participants = this.extractParticipants(rootEvent);
|
||||
|
||||
return {
|
||||
id: `nip-22:${coordinate}`,
|
||||
type: "group",
|
||||
protocol: "nip-22",
|
||||
title,
|
||||
participants,
|
||||
title: this.extractTitle(rootEvent),
|
||||
participants: this.extractParticipants(rootEvent),
|
||||
metadata: {
|
||||
rootEventId: rootEvent.id,
|
||||
rootAddress: pointer,
|
||||
@@ -247,39 +240,35 @@ export class Nip22Adapter extends ChatProtocolAdapter {
|
||||
comment: NostrEvent,
|
||||
relayHints: string[],
|
||||
): Promise<Conversation> {
|
||||
// Find root reference using uppercase tags
|
||||
const eTag = comment.tags.find((t) => t[0] === "E");
|
||||
const aTag = comment.tags.find((t) => t[0] === "A");
|
||||
const iTag = comment.tags.find((t) => t[0] === "I");
|
||||
const kTag = comment.tags.find((t) => t[0] === "K");
|
||||
|
||||
const rootKind = kTag ? parseInt(kTag[1], 10) : undefined;
|
||||
|
||||
// Try E tag (event root)
|
||||
if (eTag && eTag[1]) {
|
||||
if (eTag?.[1]) {
|
||||
const eventId = eTag[1];
|
||||
const relay = eTag[2];
|
||||
const rootPubkey = eTag[4];
|
||||
|
||||
const rootEvent = await this.fetchEvent(
|
||||
eventId,
|
||||
relay ? [relay, ...relayHints] : relayHints,
|
||||
);
|
||||
const rootEvent = await fetchEvent(eventId, {
|
||||
relayHints: relay ? [relay, ...relayHints] : relayHints,
|
||||
logPrefix: LOG_PREFIX,
|
||||
});
|
||||
|
||||
if (rootEvent) {
|
||||
const conversationRelays = await this.getCommentRelays(
|
||||
rootEvent,
|
||||
const conversationRelays = await this.buildRelays(
|
||||
rootEvent.pubkey,
|
||||
relayHints,
|
||||
);
|
||||
const title = this.extractTitle(rootEvent);
|
||||
const participants = this.extractParticipants(rootEvent, comment);
|
||||
|
||||
return {
|
||||
id: `nip-22:${eventId}`,
|
||||
type: "group",
|
||||
protocol: "nip-22",
|
||||
title,
|
||||
participants,
|
||||
title: this.extractTitle(rootEvent),
|
||||
participants: this.extractParticipants(rootEvent, comment),
|
||||
metadata: {
|
||||
rootEventId: eventId,
|
||||
providedEventId: comment.id,
|
||||
@@ -291,12 +280,12 @@ export class Nip22Adapter extends ChatProtocolAdapter {
|
||||
};
|
||||
}
|
||||
|
||||
// Root not found but we have the ID - create minimal conversation
|
||||
// Root not found - create minimal conversation
|
||||
return {
|
||||
id: `nip-22:${eventId}`,
|
||||
type: "group",
|
||||
protocol: "nip-22",
|
||||
title: `Comments on event`,
|
||||
title: "Comments on event",
|
||||
participants: rootPubkey
|
||||
? [{ pubkey: rootPubkey, role: "op" }]
|
||||
: [{ pubkey: comment.pubkey, role: "member" }],
|
||||
@@ -311,7 +300,7 @@ export class Nip22Adapter extends ChatProtocolAdapter {
|
||||
}
|
||||
|
||||
// Try A tag (addressable root)
|
||||
if (aTag && aTag[1]) {
|
||||
if (aTag?.[1]) {
|
||||
const coordinate = aTag[1];
|
||||
const parts = coordinate.split(":");
|
||||
if (parts.length >= 3) {
|
||||
@@ -320,29 +309,26 @@ export class Nip22Adapter extends ChatProtocolAdapter {
|
||||
const identifier = parts.slice(2).join(":");
|
||||
const relay = aTag[2];
|
||||
|
||||
const rootEvent = await this.fetchReplaceableEvent(
|
||||
kind,
|
||||
pubkey,
|
||||
const rootEvent = await fetchReplaceableEvent(kind, pubkey, {
|
||||
identifier,
|
||||
relay ? [relay, ...relayHints] : relayHints,
|
||||
);
|
||||
relayHints: relay ? [relay, ...relayHints] : relayHints,
|
||||
logPrefix: LOG_PREFIX,
|
||||
});
|
||||
|
||||
const rootAddress = { kind, pubkey, identifier };
|
||||
|
||||
if (rootEvent) {
|
||||
const conversationRelays = await this.getCommentRelays(
|
||||
rootEvent,
|
||||
const conversationRelays = await this.buildRelays(
|
||||
rootEvent.pubkey,
|
||||
relayHints,
|
||||
);
|
||||
const title = this.extractTitle(rootEvent);
|
||||
const participants = this.extractParticipants(rootEvent, comment);
|
||||
|
||||
return {
|
||||
id: `nip-22:${coordinate}`,
|
||||
type: "group",
|
||||
protocol: "nip-22",
|
||||
title,
|
||||
participants,
|
||||
title: this.extractTitle(rootEvent),
|
||||
participants: this.extractParticipants(rootEvent, comment),
|
||||
metadata: {
|
||||
rootEventId: rootEvent.id,
|
||||
rootAddress,
|
||||
@@ -355,7 +341,6 @@ export class Nip22Adapter extends ChatProtocolAdapter {
|
||||
};
|
||||
}
|
||||
|
||||
// Root not found but we have the address
|
||||
return {
|
||||
id: `nip-22:${coordinate}`,
|
||||
type: "group",
|
||||
@@ -373,8 +358,8 @@ export class Nip22Adapter extends ChatProtocolAdapter {
|
||||
}
|
||||
}
|
||||
|
||||
// Try I tag (external identifier like URL)
|
||||
if (iTag && iTag[1]) {
|
||||
// Try I tag (external identifier)
|
||||
if (iTag?.[1]) {
|
||||
const externalId = iTag[1];
|
||||
|
||||
return {
|
||||
@@ -386,7 +371,7 @@ export class Nip22Adapter extends ChatProtocolAdapter {
|
||||
metadata: {
|
||||
rootExternal: externalId,
|
||||
providedEventId: comment.id,
|
||||
rootKind: 0, // External content has no kind
|
||||
rootKind: 0,
|
||||
relays: relayHints.length > 0 ? relayHints : AGGREGATOR_RELAYS,
|
||||
},
|
||||
unreadCount: 0,
|
||||
@@ -407,29 +392,18 @@ export class Nip22Adapter extends ChatProtocolAdapter {
|
||||
const rootAddress = conversation.metadata?.rootAddress;
|
||||
const rootExternal = conversation.metadata?.rootExternal;
|
||||
const relays = conversation.metadata?.relays || [];
|
||||
const conversationId = conversation.id;
|
||||
|
||||
// Build filter based on root type
|
||||
// Build filters based on root type
|
||||
const filters: Filter[] = [];
|
||||
|
||||
if (rootEventId) {
|
||||
// Comments referencing this event
|
||||
filters.push({
|
||||
kinds: [1111],
|
||||
"#E": [rootEventId],
|
||||
limit: options?.limit || 100,
|
||||
});
|
||||
// Reactions on comments
|
||||
filters.push({
|
||||
kinds: [7],
|
||||
"#e": [rootEventId],
|
||||
limit: 200,
|
||||
});
|
||||
// Zaps on comments
|
||||
filters.push({
|
||||
kinds: [9735],
|
||||
"#e": [rootEventId],
|
||||
limit: 100,
|
||||
});
|
||||
filters.push({ kinds: [7, 9735], "#e": [rootEventId], limit: 200 });
|
||||
}
|
||||
|
||||
if (rootAddress) {
|
||||
@@ -453,51 +427,32 @@ export class Nip22Adapter extends ChatProtocolAdapter {
|
||||
return of([]);
|
||||
}
|
||||
|
||||
if (options?.before) {
|
||||
filters[0].until = options.before;
|
||||
}
|
||||
if (options?.after) {
|
||||
filters[0].since = options.after;
|
||||
}
|
||||
if (options?.before) filters[0].until = options.before;
|
||||
if (options?.after) filters[0].since = options.after;
|
||||
|
||||
// Clean up any existing subscription
|
||||
const conversationId = conversation.id;
|
||||
// Cleanup existing subscription
|
||||
this.cleanup(conversationId);
|
||||
|
||||
// Start persistent subscription
|
||||
const subscription = pool
|
||||
.subscription(relays, filters, { eventStore })
|
||||
.subscribe({
|
||||
next: (_response) => {
|
||||
// EOSE or event - both handled by EventStore
|
||||
},
|
||||
});
|
||||
.subscribe();
|
||||
|
||||
// Store subscription for cleanup
|
||||
this.subscriptions.set(conversationId, subscription);
|
||||
|
||||
// Build timeline observable based on root type
|
||||
const commentFilters: Filter[] = [];
|
||||
// Build timeline observable
|
||||
const commentFilter: Filter = rootEventId
|
||||
? { kinds: [1111, 7, 9735], "#E": [rootEventId] }
|
||||
: rootAddress
|
||||
? {
|
||||
kinds: [1111, 7, 9735],
|
||||
"#A": [
|
||||
`${rootAddress.kind}:${rootAddress.pubkey}:${rootAddress.identifier}`,
|
||||
],
|
||||
}
|
||||
: { kinds: [1111], "#I": [rootExternal!] };
|
||||
|
||||
if (rootEventId) {
|
||||
commentFilters.push({ kinds: [1111, 7, 9735], "#E": [rootEventId] });
|
||||
commentFilters.push({ kinds: [7, 9735], "#e": [rootEventId] });
|
||||
}
|
||||
if (rootAddress) {
|
||||
const coordinate = `${rootAddress.kind}:${rootAddress.pubkey}:${rootAddress.identifier}`;
|
||||
commentFilters.push({ kinds: [1111, 7, 9735], "#A": [coordinate] });
|
||||
}
|
||||
if (rootExternal) {
|
||||
commentFilters.push({ kinds: [1111], "#I": [rootExternal] });
|
||||
}
|
||||
|
||||
// Combine all comment sources
|
||||
const comments$ =
|
||||
commentFilters.length > 0
|
||||
? eventStore.timeline(commentFilters[0])
|
||||
: of([]);
|
||||
|
||||
// Optionally fetch root event for display
|
||||
const comments$ = eventStore.timeline(commentFilter);
|
||||
const rootEvent$ = rootEventId
|
||||
? eventStore.event(rootEventId)
|
||||
: of(undefined);
|
||||
@@ -506,27 +461,26 @@ export class Nip22Adapter extends ChatProtocolAdapter {
|
||||
map(([rootEvent, commentEvents]) => {
|
||||
const messages: Message[] = [];
|
||||
|
||||
// Add root event as first message (if it's a regular event, not addressable)
|
||||
// Add root event as first message (if available and not a comment)
|
||||
if (rootEvent && rootEvent.kind !== 1111) {
|
||||
const rootMessage = this.rootEventToMessage(
|
||||
rootEvent,
|
||||
conversationId,
|
||||
messages.push(
|
||||
eventToMessage(rootEvent, {
|
||||
conversationId,
|
||||
protocol: "nip-22",
|
||||
}),
|
||||
);
|
||||
if (rootMessage) {
|
||||
messages.push(rootMessage);
|
||||
}
|
||||
}
|
||||
|
||||
// Convert comments to messages
|
||||
const commentMessages = commentEvents
|
||||
.map((event) =>
|
||||
this.eventToMessage(event, conversationId, rootEventId),
|
||||
)
|
||||
.filter((msg): msg is Message => msg !== null);
|
||||
for (const event of commentEvents) {
|
||||
const msg = this.convertEventToMessage(
|
||||
event,
|
||||
conversationId,
|
||||
rootEventId,
|
||||
);
|
||||
if (msg) messages.push(msg);
|
||||
}
|
||||
|
||||
messages.push(...commentMessages);
|
||||
|
||||
// Sort by timestamp ascending (chronological order)
|
||||
return messages.sort((a, b) => a.timestamp - b.timestamp);
|
||||
}),
|
||||
);
|
||||
@@ -542,6 +496,7 @@ export class Nip22Adapter extends ChatProtocolAdapter {
|
||||
const rootEventId = conversation.metadata?.rootEventId;
|
||||
const rootAddress = conversation.metadata?.rootAddress;
|
||||
const relays = conversation.metadata?.relays || [];
|
||||
const conversationId = conversation.id;
|
||||
|
||||
const filters: Filter[] = [];
|
||||
|
||||
@@ -564,21 +519,16 @@ export class Nip22Adapter extends ChatProtocolAdapter {
|
||||
});
|
||||
}
|
||||
|
||||
if (filters.length === 0) {
|
||||
return [];
|
||||
}
|
||||
if (filters.length === 0) return [];
|
||||
|
||||
const events = await firstValueFrom(
|
||||
pool.request(relays, filters, { eventStore }).pipe(toArray()),
|
||||
);
|
||||
|
||||
const conversationId = conversation.id;
|
||||
|
||||
const messages = events
|
||||
.map((event) => this.eventToMessage(event, conversationId, rootEventId))
|
||||
.filter((msg): msg is Message => msg !== null);
|
||||
|
||||
return messages.reverse();
|
||||
return events
|
||||
.map((e) => this.convertEventToMessage(e, conversationId, rootEventId))
|
||||
.filter((msg): msg is Message => msg !== null)
|
||||
.reverse();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -602,16 +552,13 @@ export class Nip22Adapter extends ChatProtocolAdapter {
|
||||
const rootKind = conversation.metadata?.rootKind;
|
||||
const relays = conversation.metadata?.relays || [];
|
||||
|
||||
// Create event factory
|
||||
const factory = new EventFactory();
|
||||
factory.setSigner(activeSigner);
|
||||
|
||||
// Build NIP-22 tags
|
||||
const tags: string[][] = [];
|
||||
|
||||
// Add root reference (uppercase tags)
|
||||
if (rootEventId) {
|
||||
// Fetch root to get author
|
||||
const rootEvent = await firstValueFrom(eventStore.event(rootEventId), {
|
||||
defaultValue: undefined,
|
||||
});
|
||||
@@ -626,9 +573,7 @@ export class Nip22Adapter extends ChatProtocolAdapter {
|
||||
rootPubkey || "",
|
||||
]);
|
||||
tags.push(["K", (rootEventKind || 0).toString()]);
|
||||
if (rootPubkey) {
|
||||
tags.push(["P", rootPubkey]);
|
||||
}
|
||||
if (rootPubkey) tags.push(["P", rootPubkey]);
|
||||
} else if (rootAddress) {
|
||||
const coordinate = `${rootAddress.kind}:${rootAddress.pubkey}:${rootAddress.identifier}`;
|
||||
tags.push(["A", coordinate, relays[0] || ""]);
|
||||
@@ -636,7 +581,7 @@ export class Nip22Adapter extends ChatProtocolAdapter {
|
||||
tags.push(["P", rootAddress.pubkey]);
|
||||
} else if (rootExternal) {
|
||||
tags.push(["I", rootExternal]);
|
||||
tags.push(["K", "0"]); // External content has no kind
|
||||
tags.push(["K", "0"]);
|
||||
}
|
||||
|
||||
// Handle reply to another comment
|
||||
@@ -647,7 +592,6 @@ export class Nip22Adapter extends ChatProtocolAdapter {
|
||||
);
|
||||
|
||||
if (parentEvent) {
|
||||
// Add parent reference (lowercase tags)
|
||||
tags.push(["e", options.replyTo, relays[0] || "", parentEvent.pubkey]);
|
||||
tags.push(["k", parentEvent.kind.toString()]);
|
||||
tags.push(["p", parentEvent.pubkey]);
|
||||
@@ -661,7 +605,7 @@ export class Nip22Adapter extends ChatProtocolAdapter {
|
||||
}
|
||||
}
|
||||
|
||||
// Add NIP-92 imeta tags for blob attachments
|
||||
// Add NIP-92 imeta tags
|
||||
if (options?.blobAttachments) {
|
||||
for (const blob of options.blobAttachments) {
|
||||
const imetaParts = [`url ${blob.url}`];
|
||||
@@ -672,11 +616,9 @@ export class Nip22Adapter extends ChatProtocolAdapter {
|
||||
}
|
||||
}
|
||||
|
||||
// Create and sign kind 1111 event
|
||||
const draft = await factory.build({ kind: 1111, content, tags });
|
||||
const event = await factory.sign(draft);
|
||||
|
||||
// Publish to conversation relays
|
||||
await publishEventToRelays(event, relays);
|
||||
}
|
||||
|
||||
@@ -731,16 +673,14 @@ export class Nip22Adapter extends ChatProtocolAdapter {
|
||||
getZapConfig(message: Message, conversation: Conversation): ZapConfig {
|
||||
const relays = conversation.metadata?.relays || [];
|
||||
|
||||
const eventPointer = {
|
||||
id: message.id,
|
||||
author: message.author,
|
||||
relays,
|
||||
};
|
||||
|
||||
return {
|
||||
supported: true,
|
||||
recipientPubkey: message.author,
|
||||
eventPointer,
|
||||
eventPointer: {
|
||||
id: message.id,
|
||||
author: message.author,
|
||||
relays,
|
||||
},
|
||||
relays,
|
||||
};
|
||||
}
|
||||
@@ -752,30 +692,12 @@ export class Nip22Adapter extends ChatProtocolAdapter {
|
||||
conversation: Conversation,
|
||||
eventId: string,
|
||||
): Promise<NostrEvent | null> {
|
||||
const cachedEvent = await eventStore
|
||||
.event(eventId)
|
||||
.pipe(first())
|
||||
.toPromise();
|
||||
if (cachedEvent) {
|
||||
return cachedEvent;
|
||||
}
|
||||
|
||||
const relays = conversation.metadata?.relays || [];
|
||||
if (relays.length === 0) {
|
||||
console.warn("[NIP-22] No relays for loading reply message");
|
||||
return null;
|
||||
}
|
||||
|
||||
const filter: Filter = {
|
||||
ids: [eventId],
|
||||
limit: 1,
|
||||
};
|
||||
|
||||
const events = await firstValueFrom(
|
||||
pool.request(relays, [filter], { eventStore }).pipe(toArray()),
|
||||
);
|
||||
|
||||
return events[0] || null;
|
||||
return fetchEvent(eventId, {
|
||||
relayHints: relays,
|
||||
logPrefix: LOG_PREFIX,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -793,21 +715,43 @@ export class Nip22Adapter extends ChatProtocolAdapter {
|
||||
};
|
||||
}
|
||||
|
||||
// --- Private helpers ---
|
||||
|
||||
/**
|
||||
* Extract a readable title from root event
|
||||
* Build relay list from pubkey outboxes and hints
|
||||
*/
|
||||
private async buildRelays(
|
||||
rootPubkey: string,
|
||||
providedRelays: string[],
|
||||
): Promise<string[]> {
|
||||
const rootOutbox = await getOutboxRelays(rootPubkey, { maxRelays: 3 });
|
||||
|
||||
const activePubkey = accountManager.active$.value?.pubkey;
|
||||
const userOutbox =
|
||||
activePubkey && activePubkey !== rootPubkey
|
||||
? await getOutboxRelays(activePubkey, { maxRelays: 2 })
|
||||
: [];
|
||||
|
||||
return mergeRelays([providedRelays, rootOutbox, userOutbox], {
|
||||
maxRelays: 10,
|
||||
minRelays: 3,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract title from root event
|
||||
*/
|
||||
private extractTitle(rootEvent: NostrEvent): string {
|
||||
// Try article title first (kind 30023)
|
||||
// Try article title (kind 30023)
|
||||
if (rootEvent.kind === 30023) {
|
||||
const title = getArticleTitle(rootEvent);
|
||||
if (title) return title;
|
||||
}
|
||||
|
||||
// Try title tag
|
||||
// Try title/name tags
|
||||
const titleTag = getTagValue(rootEvent, "title");
|
||||
if (titleTag) return titleTag;
|
||||
|
||||
// Try name tag (for badges, etc.)
|
||||
const nameTag = getTagValue(rootEvent, "name");
|
||||
if (nameTag) return nameTag;
|
||||
|
||||
@@ -816,19 +760,14 @@ export class Nip22Adapter extends ChatProtocolAdapter {
|
||||
if (!content) return `Comments on kind ${rootEvent.kind}`;
|
||||
|
||||
const firstLine = content.split("\n")[0];
|
||||
if (firstLine && firstLine.length <= 50) {
|
||||
return firstLine;
|
||||
}
|
||||
|
||||
if (content.length <= 50) {
|
||||
return content;
|
||||
}
|
||||
if (firstLine && firstLine.length <= 50) return firstLine;
|
||||
if (content.length <= 50) return content;
|
||||
|
||||
return content.slice(0, 47) + "...";
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract participants from root and optional comment
|
||||
* Extract participants from root event
|
||||
*/
|
||||
private extractParticipants(
|
||||
rootEvent: NostrEvent,
|
||||
@@ -845,10 +784,7 @@ export class Nip22Adapter extends ChatProtocolAdapter {
|
||||
// Add p-tags from root
|
||||
for (const tag of rootEvent.tags) {
|
||||
if (tag[0] === "p" && tag[1] && tag[1] !== rootEvent.pubkey) {
|
||||
participants.set(tag[1], {
|
||||
pubkey: tag[1],
|
||||
role: "member",
|
||||
});
|
||||
participants.set(tag[1], { pubkey: tag[1], role: "member" });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -864,302 +800,39 @@ export class Nip22Adapter extends ChatProtocolAdapter {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get relays for the comment thread
|
||||
* Convert event to Message, handling different event types
|
||||
*/
|
||||
private async getCommentRelays(
|
||||
rootEvent: NostrEvent,
|
||||
providedRelays: string[],
|
||||
): Promise<string[]> {
|
||||
const relays = new Set<string>();
|
||||
|
||||
// 1. Provided relay hints
|
||||
providedRelays.forEach((r) => relays.add(normalizeURL(r)));
|
||||
|
||||
// 2. Root author's outbox relays
|
||||
try {
|
||||
const rootOutbox = await this.getOutboxRelays(rootEvent.pubkey);
|
||||
rootOutbox.slice(0, 3).forEach((r) => relays.add(normalizeURL(r)));
|
||||
} catch (err) {
|
||||
console.warn("[NIP-22] Failed to get root author outbox:", err);
|
||||
}
|
||||
|
||||
// 3. Active user's outbox
|
||||
const activePubkey = accountManager.active$.value?.pubkey;
|
||||
if (activePubkey && activePubkey !== rootEvent.pubkey) {
|
||||
try {
|
||||
const userOutbox = await this.getOutboxRelays(activePubkey);
|
||||
userOutbox.slice(0, 2).forEach((r) => relays.add(normalizeURL(r)));
|
||||
} catch (err) {
|
||||
console.warn("[NIP-22] Failed to get user outbox:", err);
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Fallback to aggregator relays
|
||||
if (relays.size < 3) {
|
||||
AGGREGATOR_RELAYS.forEach((r) => relays.add(r));
|
||||
}
|
||||
|
||||
return Array.from(relays).slice(0, 10);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get outbox relays for a pubkey (NIP-65)
|
||||
*/
|
||||
private async getOutboxRelays(pubkey: string): Promise<string[]> {
|
||||
const relayList = await firstValueFrom(
|
||||
eventStore.replaceable(10002, pubkey, ""),
|
||||
{ defaultValue: undefined },
|
||||
);
|
||||
|
||||
if (!relayList) return [];
|
||||
|
||||
return relayList.tags
|
||||
.filter((t) => {
|
||||
if (t[0] !== "r") return false;
|
||||
const marker = t[2];
|
||||
return !marker || marker === "write";
|
||||
})
|
||||
.map((t) => normalizeURL(t[1]))
|
||||
.slice(0, 5);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch an event by ID
|
||||
*/
|
||||
private async fetchEvent(
|
||||
eventId: string,
|
||||
relayHints: string[] = [],
|
||||
): Promise<NostrEvent | null> {
|
||||
const cached = await firstValueFrom(eventStore.event(eventId), {
|
||||
defaultValue: undefined,
|
||||
});
|
||||
if (cached) return cached;
|
||||
|
||||
const relays =
|
||||
relayHints.length > 0 ? relayHints : await this.getDefaultRelays();
|
||||
|
||||
const filter: Filter = {
|
||||
ids: [eventId],
|
||||
limit: 1,
|
||||
};
|
||||
|
||||
const events: NostrEvent[] = [];
|
||||
const obs = pool.subscription(relays, [filter], { eventStore });
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
const timeout = setTimeout(() => {
|
||||
resolve();
|
||||
}, 5000);
|
||||
|
||||
const sub = obs.subscribe({
|
||||
next: (response) => {
|
||||
if (typeof response === "string") {
|
||||
clearTimeout(timeout);
|
||||
sub.unsubscribe();
|
||||
resolve();
|
||||
} else {
|
||||
events.push(response);
|
||||
}
|
||||
},
|
||||
error: (err) => {
|
||||
clearTimeout(timeout);
|
||||
console.error(`[NIP-22] Fetch error:`, err);
|
||||
sub.unsubscribe();
|
||||
resolve();
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
return events[0] || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a replaceable event by address
|
||||
*/
|
||||
private async fetchReplaceableEvent(
|
||||
kind: number,
|
||||
pubkey: string,
|
||||
identifier: string,
|
||||
relayHints: string[] = [],
|
||||
): Promise<NostrEvent | null> {
|
||||
const cached = await firstValueFrom(
|
||||
eventStore.replaceable(kind, pubkey, identifier),
|
||||
{ defaultValue: undefined },
|
||||
);
|
||||
if (cached) return cached;
|
||||
|
||||
const relays =
|
||||
relayHints.length > 0 ? relayHints : await this.getDefaultRelays();
|
||||
|
||||
const filter: Filter = {
|
||||
kinds: [kind],
|
||||
authors: [pubkey],
|
||||
"#d": [identifier],
|
||||
limit: 1,
|
||||
};
|
||||
|
||||
const events: NostrEvent[] = [];
|
||||
const obs = pool.subscription(relays, [filter], { eventStore });
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
const timeout = setTimeout(() => {
|
||||
resolve();
|
||||
}, 5000);
|
||||
|
||||
const sub = obs.subscribe({
|
||||
next: (response) => {
|
||||
if (typeof response === "string") {
|
||||
clearTimeout(timeout);
|
||||
sub.unsubscribe();
|
||||
resolve();
|
||||
} else {
|
||||
events.push(response);
|
||||
}
|
||||
},
|
||||
error: (err) => {
|
||||
clearTimeout(timeout);
|
||||
console.error(`[NIP-22] Fetch error:`, err);
|
||||
sub.unsubscribe();
|
||||
resolve();
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
return events[0] || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get default relays
|
||||
*/
|
||||
private async getDefaultRelays(): Promise<string[]> {
|
||||
const activePubkey = accountManager.active$.value?.pubkey;
|
||||
if (activePubkey) {
|
||||
const outbox = await this.getOutboxRelays(activePubkey);
|
||||
if (outbox.length > 0) return outbox.slice(0, 5);
|
||||
}
|
||||
|
||||
return AGGREGATOR_RELAYS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert root event to Message object
|
||||
*/
|
||||
private rootEventToMessage(
|
||||
event: NostrEvent,
|
||||
conversationId: string,
|
||||
): Message | null {
|
||||
return {
|
||||
id: event.id,
|
||||
conversationId,
|
||||
author: event.pubkey,
|
||||
content: event.content,
|
||||
timestamp: event.created_at,
|
||||
type: "user",
|
||||
replyTo: undefined,
|
||||
protocol: "nip-22",
|
||||
metadata: {
|
||||
encrypted: false,
|
||||
},
|
||||
event,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert event to Message object
|
||||
*/
|
||||
private eventToMessage(
|
||||
private convertEventToMessage(
|
||||
event: NostrEvent,
|
||||
conversationId: string,
|
||||
rootEventId?: string,
|
||||
): Message | null {
|
||||
// Handle zap receipts
|
||||
// Zap receipts
|
||||
if (event.kind === 9735) {
|
||||
return this.zapToMessage(event, conversationId);
|
||||
return zapReceiptToMessage(event, {
|
||||
conversationId,
|
||||
protocol: "nip-22",
|
||||
});
|
||||
}
|
||||
|
||||
// Handle reactions - skip for now
|
||||
// Skip reactions (handled separately in UI)
|
||||
if (event.kind === 7) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Handle comments (kind 1111)
|
||||
// Comments (kind 1111) - use NIP-22 reply extraction
|
||||
if (event.kind === 1111) {
|
||||
// Find parent reference (lowercase e tag)
|
||||
const parentTag = event.tags.find((t) => t[0] === "e");
|
||||
const replyTo = parentTag?.[1] || rootEventId;
|
||||
|
||||
return {
|
||||
id: event.id,
|
||||
return eventToMessage(event, {
|
||||
conversationId,
|
||||
author: event.pubkey,
|
||||
content: event.content,
|
||||
timestamp: event.created_at,
|
||||
type: "user",
|
||||
replyTo,
|
||||
protocol: "nip-22",
|
||||
metadata: {
|
||||
encrypted: false,
|
||||
},
|
||||
event,
|
||||
};
|
||||
getReplyTo: (e) => getNip22ReplyTo(e) || rootEventId,
|
||||
});
|
||||
}
|
||||
|
||||
// Other event types (the root itself)
|
||||
return {
|
||||
id: event.id,
|
||||
// Other events (root)
|
||||
return eventToMessage(event, {
|
||||
conversationId,
|
||||
author: event.pubkey,
|
||||
content: event.content,
|
||||
timestamp: event.created_at,
|
||||
type: "user",
|
||||
replyTo: undefined,
|
||||
protocol: "nip-22",
|
||||
metadata: {
|
||||
encrypted: false,
|
||||
},
|
||||
event,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert zap receipt to Message
|
||||
*/
|
||||
private zapToMessage(
|
||||
zapReceipt: NostrEvent,
|
||||
conversationId: string,
|
||||
): Message {
|
||||
const amount = getZapAmount(zapReceipt);
|
||||
const sender = getZapSender(zapReceipt);
|
||||
const recipient = getZapRecipient(zapReceipt);
|
||||
|
||||
const eTag = zapReceipt.tags.find((t) => t[0] === "e");
|
||||
const replyTo = eTag?.[1];
|
||||
|
||||
const zapRequestTag = zapReceipt.tags.find((t) => t[0] === "description");
|
||||
let comment = "";
|
||||
if (zapRequestTag && zapRequestTag[1]) {
|
||||
try {
|
||||
const zapRequest = JSON.parse(zapRequestTag[1]) as NostrEvent;
|
||||
comment = zapRequest.content || "";
|
||||
} catch {
|
||||
// Invalid JSON
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
id: zapReceipt.id,
|
||||
conversationId,
|
||||
author: sender || zapReceipt.pubkey,
|
||||
content: comment,
|
||||
timestamp: zapReceipt.created_at,
|
||||
type: "zap",
|
||||
replyTo,
|
||||
protocol: "nip-22",
|
||||
metadata: {
|
||||
zapAmount: amount,
|
||||
zapRecipient: recipient,
|
||||
},
|
||||
event: zapReceipt,
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
188
src/lib/chat/utils/event-fetcher.ts
Normal file
188
src/lib/chat/utils/event-fetcher.ts
Normal file
@@ -0,0 +1,188 @@
|
||||
/**
|
||||
* Shared event fetching utilities for chat adapters
|
||||
*
|
||||
* Provides reusable functions for fetching events from relays
|
||||
* with EventStore caching and timeout handling.
|
||||
*/
|
||||
|
||||
import { firstValueFrom } from "rxjs";
|
||||
import type { Filter } from "nostr-tools";
|
||||
import type { NostrEvent } from "@/types/nostr";
|
||||
import eventStore from "@/services/event-store";
|
||||
import pool from "@/services/relay-pool";
|
||||
import { getOutboxRelays, AGGREGATOR_RELAYS } from "./relay-utils";
|
||||
import accountManager from "@/services/accounts";
|
||||
|
||||
export interface FetchEventOptions {
|
||||
/** Relay hints to try first */
|
||||
relayHints?: string[];
|
||||
/** Timeout in milliseconds (default: 5000) */
|
||||
timeout?: number;
|
||||
/** Log prefix for debugging */
|
||||
logPrefix?: string;
|
||||
}
|
||||
|
||||
export interface FetchReplaceableOptions extends FetchEventOptions {
|
||||
/** The d-tag identifier */
|
||||
identifier: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch an event by ID
|
||||
*
|
||||
* First checks EventStore cache, then fetches from relays.
|
||||
* Uses provided relay hints, falling back to user's outbox + aggregator relays.
|
||||
*
|
||||
* @param eventId - The event ID to fetch
|
||||
* @param options - Fetch options
|
||||
* @returns The event or null if not found
|
||||
*/
|
||||
export async function fetchEvent(
|
||||
eventId: string,
|
||||
options: FetchEventOptions = {},
|
||||
): Promise<NostrEvent | null> {
|
||||
const {
|
||||
relayHints = [],
|
||||
timeout = 5000,
|
||||
logPrefix = "[ChatUtils]",
|
||||
} = options;
|
||||
|
||||
// Check EventStore first
|
||||
const cached = await firstValueFrom(eventStore.event(eventId), {
|
||||
defaultValue: undefined,
|
||||
});
|
||||
if (cached) return cached;
|
||||
|
||||
// Determine relays to use
|
||||
const relays = await resolveRelays(relayHints);
|
||||
|
||||
if (relays.length === 0) {
|
||||
console.warn(`${logPrefix} No relays available for fetching event`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const filter: Filter = {
|
||||
ids: [eventId],
|
||||
limit: 1,
|
||||
};
|
||||
|
||||
return fetchWithTimeout(relays, [filter], timeout, logPrefix);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a replaceable event by kind, pubkey, and identifier
|
||||
*
|
||||
* First checks EventStore cache, then fetches from relays.
|
||||
*
|
||||
* @param kind - Event kind
|
||||
* @param pubkey - Author pubkey
|
||||
* @param options - Fetch options including identifier
|
||||
* @returns The event or null if not found
|
||||
*/
|
||||
export async function fetchReplaceableEvent(
|
||||
kind: number,
|
||||
pubkey: string,
|
||||
options: FetchReplaceableOptions,
|
||||
): Promise<NostrEvent | null> {
|
||||
const {
|
||||
identifier,
|
||||
relayHints = [],
|
||||
timeout = 5000,
|
||||
logPrefix = "[ChatUtils]",
|
||||
} = options;
|
||||
|
||||
// Check EventStore first
|
||||
const cached = await firstValueFrom(
|
||||
eventStore.replaceable(kind, pubkey, identifier),
|
||||
{ defaultValue: undefined },
|
||||
);
|
||||
if (cached) return cached;
|
||||
|
||||
// Determine relays to use
|
||||
const relays = await resolveRelays(relayHints);
|
||||
|
||||
if (relays.length === 0) {
|
||||
console.warn(
|
||||
`${logPrefix} No relays available for fetching replaceable event`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
const filter: Filter = {
|
||||
kinds: [kind],
|
||||
authors: [pubkey],
|
||||
"#d": [identifier],
|
||||
limit: 1,
|
||||
};
|
||||
|
||||
return fetchWithTimeout(relays, [filter], timeout, logPrefix);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch events matching a filter with timeout
|
||||
*
|
||||
* Returns the first event found or null after timeout/EOSE.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
async function fetchWithTimeout(
|
||||
relays: string[],
|
||||
filters: Filter[],
|
||||
timeout: number,
|
||||
logPrefix: string,
|
||||
): Promise<NostrEvent | null> {
|
||||
const events: NostrEvent[] = [];
|
||||
const obs = pool.subscription(relays, filters, { eventStore });
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
const timer = setTimeout(() => {
|
||||
resolve();
|
||||
}, timeout);
|
||||
|
||||
const sub = obs.subscribe({
|
||||
next: (response) => {
|
||||
if (typeof response === "string") {
|
||||
// EOSE received
|
||||
clearTimeout(timer);
|
||||
sub.unsubscribe();
|
||||
resolve();
|
||||
} else {
|
||||
events.push(response);
|
||||
}
|
||||
},
|
||||
error: (err) => {
|
||||
clearTimeout(timer);
|
||||
console.error(`${logPrefix} Fetch error:`, err);
|
||||
sub.unsubscribe();
|
||||
resolve();
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
return events[0] || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve relays to use for fetching
|
||||
*
|
||||
* Priority: provided hints > user's outbox > aggregator relays
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
async function resolveRelays(relayHints: string[]): Promise<string[]> {
|
||||
if (relayHints.length > 0) {
|
||||
return relayHints;
|
||||
}
|
||||
|
||||
// Try user's outbox relays
|
||||
const activePubkey = accountManager.active$.value?.pubkey;
|
||||
if (activePubkey) {
|
||||
const outbox = await getOutboxRelays(activePubkey);
|
||||
if (outbox.length > 0) {
|
||||
return outbox.slice(0, 5);
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to aggregator relays
|
||||
return AGGREGATOR_RELAYS;
|
||||
}
|
||||
53
src/lib/chat/utils/index.ts
Normal file
53
src/lib/chat/utils/index.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* Shared utilities for chat adapters
|
||||
*
|
||||
* This module provides reusable utilities for:
|
||||
* - Event fetching (with caching and timeout handling)
|
||||
* - Relay resolution (outbox/inbox relays, merging)
|
||||
* - Message conversion (zaps, nutzaps, generic events)
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* import {
|
||||
* fetchEvent,
|
||||
* fetchReplaceableEvent,
|
||||
* getOutboxRelays,
|
||||
* mergeRelays,
|
||||
* zapReceiptToMessage,
|
||||
* eventToMessage,
|
||||
* getNip10ReplyTo,
|
||||
* } from "@/lib/chat/utils";
|
||||
* ```
|
||||
*/
|
||||
|
||||
// Event fetching utilities
|
||||
export {
|
||||
fetchEvent,
|
||||
fetchReplaceableEvent,
|
||||
type FetchEventOptions,
|
||||
type FetchReplaceableOptions,
|
||||
} from "./event-fetcher";
|
||||
|
||||
// Relay utilities
|
||||
export {
|
||||
getOutboxRelays,
|
||||
getInboxRelays,
|
||||
mergeRelays,
|
||||
collectOutboxRelays,
|
||||
AGGREGATOR_RELAYS,
|
||||
type OutboxRelayOptions,
|
||||
type MergeRelaysOptions,
|
||||
} from "./relay-utils";
|
||||
|
||||
// Message conversion utilities
|
||||
export {
|
||||
zapReceiptToMessage,
|
||||
nutzapToMessage,
|
||||
eventToMessage,
|
||||
getNip10ReplyTo,
|
||||
getNip22ReplyTo,
|
||||
getQTagReplyTo,
|
||||
type ZapToMessageOptions,
|
||||
type NutzapToMessageOptions,
|
||||
type EventToMessageOptions,
|
||||
} from "./message-utils";
|
||||
254
src/lib/chat/utils/message-utils.ts
Normal file
254
src/lib/chat/utils/message-utils.ts
Normal file
@@ -0,0 +1,254 @@
|
||||
/**
|
||||
* Shared message utilities for chat adapters
|
||||
*
|
||||
* Provides reusable functions for converting Nostr events
|
||||
* to chat Message objects.
|
||||
*/
|
||||
|
||||
import type { NostrEvent } from "@/types/nostr";
|
||||
import type { Message, ChatProtocol } from "@/types/chat";
|
||||
import {
|
||||
getZapAmount,
|
||||
getZapSender,
|
||||
getZapRecipient,
|
||||
getZapRequest,
|
||||
} from "applesauce-common/helpers/zap";
|
||||
|
||||
export interface ZapToMessageOptions {
|
||||
/** The conversation ID for the message */
|
||||
conversationId: string;
|
||||
/** The protocol to set on the message */
|
||||
protocol: ChatProtocol;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a zap receipt (kind 9735) to a Message
|
||||
*
|
||||
* Extracts zap metadata using applesauce helpers and builds
|
||||
* a Message object with type "zap".
|
||||
*
|
||||
* @param zapReceipt - The kind 9735 zap receipt event
|
||||
* @param options - Options including conversationId and protocol
|
||||
* @returns A Message object with type "zap"
|
||||
*/
|
||||
export function zapReceiptToMessage(
|
||||
zapReceipt: NostrEvent,
|
||||
options: ZapToMessageOptions,
|
||||
): Message {
|
||||
const { conversationId, protocol } = options;
|
||||
|
||||
// Extract zap metadata using applesauce helpers
|
||||
const amount = getZapAmount(zapReceipt);
|
||||
const sender = getZapSender(zapReceipt);
|
||||
const recipient = getZapRecipient(zapReceipt);
|
||||
const zapRequest = getZapRequest(zapReceipt);
|
||||
|
||||
// Convert from msats to sats
|
||||
const amountInSats = amount ? Math.floor(amount / 1000) : 0;
|
||||
|
||||
// Get zap comment from request content
|
||||
const comment = zapRequest?.content || "";
|
||||
|
||||
// Find the event being zapped (e-tag)
|
||||
const eTag = zapReceipt.tags.find((t) => t[0] === "e");
|
||||
const replyTo = eTag?.[1];
|
||||
|
||||
return {
|
||||
id: zapReceipt.id,
|
||||
conversationId,
|
||||
author: sender || zapReceipt.pubkey,
|
||||
content: comment,
|
||||
timestamp: zapReceipt.created_at,
|
||||
type: "zap",
|
||||
replyTo,
|
||||
protocol,
|
||||
metadata: {
|
||||
encrypted: false,
|
||||
zapAmount: amountInSats,
|
||||
zapRecipient: recipient,
|
||||
},
|
||||
event: zapReceipt,
|
||||
};
|
||||
}
|
||||
|
||||
export interface NutzapToMessageOptions extends ZapToMessageOptions {}
|
||||
|
||||
/**
|
||||
* Convert a nutzap event (kind 9321) to a Message
|
||||
*
|
||||
* NIP-61 nutzaps are P2PK-locked Cashu token transfers.
|
||||
* Extracts proof amounts and builds a Message with type "zap".
|
||||
*
|
||||
* @param nutzapEvent - The kind 9321 nutzap event
|
||||
* @param options - Options including conversationId and protocol
|
||||
* @returns A Message object with type "zap"
|
||||
*/
|
||||
export function nutzapToMessage(
|
||||
nutzapEvent: NostrEvent,
|
||||
options: NutzapToMessageOptions,
|
||||
): Message {
|
||||
const { conversationId, protocol } = options;
|
||||
|
||||
// Sender is the event author
|
||||
const sender = nutzapEvent.pubkey;
|
||||
|
||||
// Recipient is the p-tag value
|
||||
const pTag = nutzapEvent.tags.find((t) => t[0] === "p");
|
||||
const recipient = pTag?.[1] || "";
|
||||
|
||||
// Reply target is the e-tag (the event being nutzapped)
|
||||
const eTag = nutzapEvent.tags.find((t) => t[0] === "e");
|
||||
const replyTo = eTag?.[1];
|
||||
|
||||
// Amount is sum of proof amounts from all proof tags
|
||||
// NIP-61 allows multiple proof tags, each containing JSON-encoded Cashu proofs
|
||||
let amount = 0;
|
||||
for (const tag of nutzapEvent.tags) {
|
||||
if (tag[0] === "proof" && tag[1]) {
|
||||
try {
|
||||
const proof = JSON.parse(tag[1]);
|
||||
// Proof can be a single object or an array of proofs
|
||||
if (Array.isArray(proof)) {
|
||||
amount += proof.reduce(
|
||||
(sum: number, p: { amount?: number }) => sum + (p.amount || 0),
|
||||
0,
|
||||
);
|
||||
} else if (typeof proof === "object" && proof.amount) {
|
||||
amount += proof.amount;
|
||||
}
|
||||
} catch {
|
||||
// Invalid proof JSON, skip this tag
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Unit defaults to "sat" per NIP-61
|
||||
const unitTag = nutzapEvent.tags.find((t) => t[0] === "unit");
|
||||
const unit = unitTag?.[1] || "sat";
|
||||
|
||||
// Comment is in the content field
|
||||
const comment = nutzapEvent.content || "";
|
||||
|
||||
return {
|
||||
id: nutzapEvent.id,
|
||||
conversationId,
|
||||
author: sender,
|
||||
content: comment,
|
||||
timestamp: nutzapEvent.created_at,
|
||||
type: "zap",
|
||||
replyTo,
|
||||
protocol,
|
||||
metadata: {
|
||||
encrypted: false,
|
||||
zapAmount: amount,
|
||||
zapRecipient: recipient,
|
||||
nutzapUnit: unit,
|
||||
},
|
||||
event: nutzapEvent,
|
||||
};
|
||||
}
|
||||
|
||||
export interface EventToMessageOptions {
|
||||
/** The conversation ID for the message */
|
||||
conversationId: string;
|
||||
/** The protocol to set on the message */
|
||||
protocol: ChatProtocol;
|
||||
/** Function to extract replyTo from the event */
|
||||
getReplyTo?: (event: NostrEvent) => string | undefined;
|
||||
/** Message type (defaults to "user") */
|
||||
type?: "user" | "system";
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a generic event to a Message
|
||||
*
|
||||
* This is a base conversion that works for most chat message types.
|
||||
* Adapters can customize the replyTo extraction via options.
|
||||
*
|
||||
* @param event - The Nostr event to convert
|
||||
* @param options - Options including conversationId, protocol, and replyTo extractor
|
||||
* @returns A Message object
|
||||
*/
|
||||
export function eventToMessage(
|
||||
event: NostrEvent,
|
||||
options: EventToMessageOptions,
|
||||
): Message {
|
||||
const { conversationId, protocol, getReplyTo, type = "user" } = options;
|
||||
|
||||
const replyTo = getReplyTo ? getReplyTo(event) : undefined;
|
||||
|
||||
return {
|
||||
id: event.id,
|
||||
conversationId,
|
||||
author: event.pubkey,
|
||||
content: event.content,
|
||||
timestamp: event.created_at,
|
||||
type,
|
||||
replyTo,
|
||||
protocol,
|
||||
metadata: {
|
||||
encrypted: false,
|
||||
},
|
||||
event,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract reply-to event ID from NIP-10 style e-tags
|
||||
*
|
||||
* Looks for "reply" marker first, then falls back to root.
|
||||
* Works for kind 1 (notes) and kind 1311 (live chat).
|
||||
*
|
||||
* @param event - The event to extract reply-to from
|
||||
* @param rootEventId - Optional root event ID (for fallback)
|
||||
* @returns The reply-to event ID or undefined
|
||||
*/
|
||||
export function getNip10ReplyTo(
|
||||
event: NostrEvent,
|
||||
rootEventId?: string,
|
||||
): string | undefined {
|
||||
const eTags = event.tags.filter((t) => t[0] === "e");
|
||||
|
||||
// Find explicit reply marker
|
||||
const replyTag = eTags.find((t) => t[3] === "reply");
|
||||
if (replyTag) return replyTag[1];
|
||||
|
||||
// Find explicit root marker (if no reply, it's a direct reply to root)
|
||||
const rootTag = eTags.find((t) => t[3] === "root");
|
||||
if (rootTag) return rootTag[1];
|
||||
|
||||
// Legacy: single e-tag means reply to that event
|
||||
if (eTags.length === 1 && !eTags[0][3]) {
|
||||
return eTags[0][1];
|
||||
}
|
||||
|
||||
// Fallback to provided root
|
||||
return rootEventId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract reply-to event ID from NIP-22 style e-tags (comments)
|
||||
*
|
||||
* NIP-22 uses lowercase e tag for parent reference.
|
||||
*
|
||||
* @param event - The kind 1111 comment event
|
||||
* @returns The reply-to event ID or undefined
|
||||
*/
|
||||
export function getNip22ReplyTo(event: NostrEvent): string | undefined {
|
||||
// Lowercase e tag is the parent comment reference
|
||||
const parentTag = event.tags.find((t) => t[0] === "e");
|
||||
return parentTag?.[1];
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract reply-to event ID from q-tag (NIP-29 style)
|
||||
*
|
||||
* NIP-29 groups use q-tag for quote/reply references.
|
||||
*
|
||||
* @param event - The event to extract reply-to from
|
||||
* @returns The reply-to event ID or undefined
|
||||
*/
|
||||
export function getQTagReplyTo(event: NostrEvent): string | undefined {
|
||||
const qTag = event.tags.find((t) => t[0] === "q");
|
||||
return qTag?.[1];
|
||||
}
|
||||
223
src/lib/chat/utils/relay-utils.ts
Normal file
223
src/lib/chat/utils/relay-utils.ts
Normal file
@@ -0,0 +1,223 @@
|
||||
/**
|
||||
* Shared relay utilities for chat adapters
|
||||
*
|
||||
* Provides reusable functions for relay selection,
|
||||
* outbox resolution, and relay set merging.
|
||||
*/
|
||||
|
||||
import { firstValueFrom } from "rxjs";
|
||||
import { normalizeURL } from "applesauce-core/helpers";
|
||||
import eventStore from "@/services/event-store";
|
||||
import { AGGREGATOR_RELAYS as LOADERS_AGGREGATOR_RELAYS } from "@/services/loaders";
|
||||
|
||||
// Re-export aggregator relays for convenience
|
||||
export const AGGREGATOR_RELAYS = LOADERS_AGGREGATOR_RELAYS;
|
||||
|
||||
export interface OutboxRelayOptions {
|
||||
/** Maximum number of relays to return (default: 5) */
|
||||
maxRelays?: number;
|
||||
/** Log prefix for debugging */
|
||||
logPrefix?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get outbox (write) relays for a pubkey via NIP-65
|
||||
*
|
||||
* Fetches kind 10002 relay list and extracts write relays.
|
||||
* Falls back to empty array if no relay list found.
|
||||
*
|
||||
* @param pubkey - The pubkey to get outbox relays for
|
||||
* @param options - Options
|
||||
* @returns Array of normalized relay URLs
|
||||
*/
|
||||
export async function getOutboxRelays(
|
||||
pubkey: string,
|
||||
options: OutboxRelayOptions = {},
|
||||
): Promise<string[]> {
|
||||
const { maxRelays = 5, logPrefix = "[RelayUtils]" } = options;
|
||||
|
||||
try {
|
||||
const relayList = await firstValueFrom(
|
||||
eventStore.replaceable(10002, pubkey, ""),
|
||||
{ defaultValue: undefined },
|
||||
);
|
||||
|
||||
if (!relayList) return [];
|
||||
|
||||
// Extract write relays (r tags with "write" marker or no marker)
|
||||
const writeRelays = relayList.tags
|
||||
.filter((t) => {
|
||||
if (t[0] !== "r") return false;
|
||||
const marker = t[2];
|
||||
return !marker || marker === "write";
|
||||
})
|
||||
.map((t) => {
|
||||
try {
|
||||
return normalizeURL(t[1]);
|
||||
} catch {
|
||||
return t[1]; // Return unnormalized if normalization fails
|
||||
}
|
||||
})
|
||||
.filter(Boolean);
|
||||
|
||||
return writeRelays.slice(0, maxRelays);
|
||||
} catch (err) {
|
||||
console.warn(
|
||||
`${logPrefix} Failed to get outbox relays for ${pubkey}:`,
|
||||
err,
|
||||
);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get inbox (read) relays for a pubkey via NIP-65
|
||||
*
|
||||
* Fetches kind 10002 relay list and extracts read relays.
|
||||
* Falls back to empty array if no relay list found.
|
||||
*
|
||||
* @param pubkey - The pubkey to get inbox relays for
|
||||
* @param options - Options
|
||||
* @returns Array of normalized relay URLs
|
||||
*/
|
||||
export async function getInboxRelays(
|
||||
pubkey: string,
|
||||
options: OutboxRelayOptions = {},
|
||||
): Promise<string[]> {
|
||||
const { maxRelays = 5, logPrefix = "[RelayUtils]" } = options;
|
||||
|
||||
try {
|
||||
const relayList = await firstValueFrom(
|
||||
eventStore.replaceable(10002, pubkey, ""),
|
||||
{ defaultValue: undefined },
|
||||
);
|
||||
|
||||
if (!relayList) return [];
|
||||
|
||||
// Extract read relays (r tags with "read" marker or no marker)
|
||||
const readRelays = relayList.tags
|
||||
.filter((t) => {
|
||||
if (t[0] !== "r") return false;
|
||||
const marker = t[2];
|
||||
return !marker || marker === "read";
|
||||
})
|
||||
.map((t) => {
|
||||
try {
|
||||
return normalizeURL(t[1]);
|
||||
} catch {
|
||||
return t[1];
|
||||
}
|
||||
})
|
||||
.filter(Boolean);
|
||||
|
||||
return readRelays.slice(0, maxRelays);
|
||||
} catch (err) {
|
||||
console.warn(`${logPrefix} Failed to get inbox relays for ${pubkey}:`, err);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export interface MergeRelaysOptions {
|
||||
/** Maximum total relays to return (default: 10) */
|
||||
maxRelays?: number;
|
||||
/** Fallback relays if result is empty or below minimum */
|
||||
fallbackRelays?: string[];
|
||||
/** Minimum relays needed before adding fallback (default: 3) */
|
||||
minRelays?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge multiple relay sources into a deduplicated, normalized list
|
||||
*
|
||||
* Relays are added in order of priority (first sources have higher priority).
|
||||
* Duplicates are removed using normalized URLs.
|
||||
*
|
||||
* @param relaySources - Arrays of relay URLs in priority order
|
||||
* @param options - Merge options
|
||||
* @returns Deduplicated array of normalized relay URLs
|
||||
*/
|
||||
export function mergeRelays(
|
||||
relaySources: string[][],
|
||||
options: MergeRelaysOptions = {},
|
||||
): string[] {
|
||||
const {
|
||||
maxRelays = 10,
|
||||
fallbackRelays = AGGREGATOR_RELAYS,
|
||||
minRelays = 3,
|
||||
} = options;
|
||||
|
||||
const seen = new Set<string>();
|
||||
const result: string[] = [];
|
||||
|
||||
// Add relays from each source in order
|
||||
for (const source of relaySources) {
|
||||
for (const relay of source) {
|
||||
if (!relay) continue;
|
||||
|
||||
try {
|
||||
const normalized = normalizeURL(relay);
|
||||
if (!seen.has(normalized)) {
|
||||
seen.add(normalized);
|
||||
result.push(normalized);
|
||||
}
|
||||
} catch {
|
||||
// Skip invalid URLs
|
||||
}
|
||||
|
||||
// Stop if we have enough
|
||||
if (result.length >= maxRelays) {
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add fallback relays if we don't have enough
|
||||
if (result.length < minRelays) {
|
||||
for (const relay of fallbackRelays) {
|
||||
try {
|
||||
const normalized = normalizeURL(relay);
|
||||
if (!seen.has(normalized)) {
|
||||
seen.add(normalized);
|
||||
result.push(normalized);
|
||||
}
|
||||
} catch {
|
||||
// Skip invalid URLs
|
||||
}
|
||||
|
||||
if (result.length >= maxRelays) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect relays from multiple pubkeys' outboxes
|
||||
*
|
||||
* Fetches outbox relays for each pubkey and merges them.
|
||||
* Useful for getting relays for thread participants.
|
||||
*
|
||||
* @param pubkeys - Array of pubkeys to get outboxes for
|
||||
* @param options - Options including max relays per pubkey
|
||||
* @returns Merged array of relay URLs
|
||||
*/
|
||||
export async function collectOutboxRelays(
|
||||
pubkeys: string[],
|
||||
options: OutboxRelayOptions & MergeRelaysOptions = {},
|
||||
): Promise<string[]> {
|
||||
const { maxRelays: perPubkeyMax = 3 } = options;
|
||||
|
||||
const relaySources: string[][] = [];
|
||||
|
||||
for (const pubkey of pubkeys.slice(0, 5)) {
|
||||
// Limit to 5 pubkeys
|
||||
const outbox = await getOutboxRelays(pubkey, { maxRelays: perPubkeyMax });
|
||||
if (outbox.length > 0) {
|
||||
relaySources.push(outbox);
|
||||
}
|
||||
}
|
||||
|
||||
return mergeRelays(relaySources, options);
|
||||
}
|
||||
Reference in New Issue
Block a user