feat: implement NIP-17 encrypted DM adapter

Add NIP-17 private direct messaging support using NIP-44 encryption
and NIP-59 gift wrapping for metadata privacy.

Key features:
- End-to-end encrypted messaging with NIP-44
- Gift-wrapped messages (kind 1059) for metadata privacy
- Fetches DM inbox relays (kind 10050) with fallback to NIP-65
- Caches decrypted rumors via applesauce's unlockGiftWrap()
- Sends copies to both recipient and self for device sync
- Supports replies via e-tag markers

The adapter is registered in chat-parser.ts and ChatViewer now
supports the nip-17 protocol. Users can start DMs with:
  chat npub1...
  chat alice@example.com
  chat <hex-pubkey>
This commit is contained in:
Claude
2026-01-12 21:36:24 +00:00
parent 20eb19bdbb
commit aa5b597a2b
3 changed files with 752 additions and 51 deletions

View File

@@ -14,6 +14,7 @@ import type {
LiveActivityMetadata,
} from "@/types/chat";
// import { NipC7Adapter } from "@/lib/chat/adapters/nip-c7-adapter"; // Coming soon
import { Nip17Adapter } from "@/lib/chat/adapters/nip-17-adapter";
import { Nip29Adapter } from "@/lib/chat/adapters/nip-29-adapter";
import { Nip53Adapter } from "@/lib/chat/adapters/nip-53-adapter";
import type { ChatProtocolAdapter } from "@/lib/chat/adapters/base-adapter";
@@ -508,7 +509,9 @@ export function ChatViewer({
// Handle NIP badge click
const handleNipClick = useCallback(() => {
if (conversation?.protocol === "nip-29") {
if (conversation?.protocol === "nip-17") {
addWindow("nip", { number: 17 });
} else if (conversation?.protocol === "nip-29") {
addWindow("nip", { number: 29 });
} else if (conversation?.protocol === "nip-53") {
addWindow("nip", { number: 53 });
@@ -631,22 +634,16 @@ export function ChatViewer({
)}
{/* Protocol Type - Clickable */}
<div className="flex items-center gap-1.5 text-xs">
{(conversation.type === "group" ||
conversation.type === "live-chat") && (
<button
onClick={(e) => {
e.stopPropagation();
handleNipClick();
}}
className="rounded bg-primary-foreground/20 px-1.5 py-0.5 font-mono hover:bg-primary-foreground/30 transition-colors cursor-pointer text-primary-foreground"
>
{conversation.protocol.toUpperCase()}
</button>
)}
{(conversation.type === "group" ||
conversation.type === "live-chat") && (
<span className="text-primary-foreground/60"></span>
)}
<button
onClick={(e) => {
e.stopPropagation();
handleNipClick();
}}
className="rounded bg-primary-foreground/20 px-1.5 py-0.5 font-mono hover:bg-primary-foreground/30 transition-colors cursor-pointer text-primary-foreground"
>
{conversation.protocol.toUpperCase()}
</button>
<span className="text-primary-foreground/60"></span>
<span className="capitalize text-primary-foreground/80">
{conversation.type}
</span>
@@ -678,15 +675,12 @@ export function ChatViewer({
<div className="flex items-center gap-2 text-xs text-muted-foreground p-1">
<MembersDropdown participants={derivedParticipants} />
<RelaysDropdown conversation={conversation} />
{(conversation.type === "group" ||
conversation.type === "live-chat") && (
<button
onClick={handleNipClick}
className="rounded bg-muted px-1.5 py-0.5 font-mono hover:bg-muted/80 transition-colors cursor-pointer"
>
{conversation.protocol.toUpperCase()}
</button>
)}
<button
onClick={handleNipClick}
className="rounded bg-muted px-1.5 py-0.5 font-mono hover:bg-muted/80 transition-colors cursor-pointer"
>
{conversation.protocol.toUpperCase()}
</button>
</div>
</div>
</div>
@@ -800,18 +794,17 @@ export function ChatViewer({
/**
* Get the appropriate adapter for a protocol
* Currently NIP-29 (relay-based groups) and NIP-53 (live activity chat) are supported
* Other protocols will be enabled in future phases
* Currently NIP-17, NIP-29, and NIP-53 are supported
*/
function getAdapter(protocol: ChatProtocol): ChatProtocolAdapter {
switch (protocol) {
// case "nip-c7": // Phase 1 - Simple chat (coming soon)
// case "nip-c7": // Simple chat (disabled - NIP-17 preferred)
// return new NipC7Adapter();
case "nip-17":
return new Nip17Adapter();
case "nip-29":
return new Nip29Adapter();
// case "nip-17": // Phase 2 - Encrypted DMs (coming soon)
// return new Nip17Adapter();
// case "nip-28": // Phase 3 - Public channels (coming soon)
// case "nip-28": // Public channels (coming soon)
// return new Nip28Adapter();
case "nip-53":
return new Nip53Adapter();

View File

@@ -1,9 +1,9 @@
import type { ChatCommandResult } from "@/types/chat";
// import { NipC7Adapter } from "./chat/adapters/nip-c7-adapter";
import { Nip17Adapter } from "./chat/adapters/nip-17-adapter";
import { Nip29Adapter } from "./chat/adapters/nip-29-adapter";
import { Nip53Adapter } from "./chat/adapters/nip-53-adapter";
// Import other adapters as they're implemented
// import { Nip17Adapter } from "./chat/adapters/nip-17-adapter";
// import { Nip28Adapter } from "./chat/adapters/nip-28-adapter";
/**
@@ -36,11 +36,11 @@ export function parseChatCommand(args: string[]): ChatCommandResult {
// Try each adapter in priority order
const adapters = [
// new Nip17Adapter(), // Phase 2
// new Nip28Adapter(), // Phase 3
new Nip29Adapter(), // Phase 4 - Relay groups
new Nip53Adapter(), // Phase 5 - Live activity chat
// new NipC7Adapter(), // Phase 1 - Simple chat (disabled for now)
new Nip29Adapter(), // Relay groups (NIP-29)
new Nip53Adapter(), // Live activity chat (NIP-53)
new Nip17Adapter(), // Encrypted DMs (NIP-17) - checked after group/live formats
// new NipC7Adapter(), // Simple chat (disabled - NIP-17 preferred)
];
for (const adapter of adapters) {
@@ -57,20 +57,18 @@ export function parseChatCommand(args: string[]): ChatCommandResult {
throw new Error(
`Unable to determine chat protocol from identifier: ${identifier}
Currently supported formats:
- relay.com'group-id (NIP-29 relay group, wss:// prefix optional)
Examples:
chat relay.example.com'bitcoin-dev
chat wss://relay.example.com'nostr-dev
- naddr1... (NIP-29 group metadata, kind 39000)
Example:
chat naddr1qqxnzdesxqmnxvpexqmny...
- naddr1... (NIP-53 live activity chat, kind 30311)
Example:
chat naddr1... (live stream address)
Supported formats:
- npub1... / nprofile1... (NIP-17 encrypted DM)
- hex pubkey (NIP-17 encrypted DM)
- user@domain.com (NIP-05 → NIP-17 encrypted DM)
- relay.com'group-id (NIP-29 relay group)
- naddr1... kind 39000 (NIP-29 group metadata)
- naddr1... kind 30311 (NIP-53 live activity chat)
More formats coming soon:
- npub/nprofile/hex pubkey (NIP-C7/NIP-17 direct messages)
- note/nevent (NIP-28 public channels)`,
Examples:
chat npub1abc123... # DM with user
chat alice@example.com # DM via NIP-05
chat relay.example.com'bitcoin # Join relay group
chat naddr1... # Live stream chat`,
);
}

View File

@@ -0,0 +1,710 @@
import { Observable, firstValueFrom, from, of } from "rxjs";
import { map, first, switchMap, catchError } from "rxjs/operators";
import { nip19 } from "nostr-tools";
import type { Filter } from "nostr-tools";
import { ChatProtocolAdapter, type SendMessageOptions } from "./base-adapter";
import type {
Conversation,
Message,
ProtocolIdentifier,
ChatCapabilities,
LoadMessagesOptions,
} from "@/types/chat";
import type { NostrEvent } from "@/types/nostr";
import eventStore from "@/services/event-store";
import pool from "@/services/relay-pool";
import { publishEventToRelays } from "@/services/hub";
import accountManager from "@/services/accounts";
import { isNip05, resolveNip05 } from "@/lib/nip05";
import { getDisplayName, getTagValues } from "@/lib/nostr-utils";
import { isValidHexPubkey } from "@/lib/nostr-validation";
import { getProfileContent } from "applesauce-core/helpers";
import { getRelaysFromList } from "applesauce-common/helpers";
import { EventFactory } from "applesauce-core/event-factory";
import { addressLoader, AGGREGATOR_RELAYS } from "@/services/loaders";
import {
unlockGiftWrap,
isGiftWrapUnlocked,
getGiftWrapRumor,
type Rumor,
} from "applesauce-common/helpers/gift-wrap";
import { getConversationParticipants } from "applesauce-common/helpers/messages";
import {
getWrappedMessageSender,
getWrappedMessageParent,
} from "applesauce-common/helpers/wrapped-messages";
import { GiftWrapBlueprint } from "applesauce-common/blueprints/gift-wrap";
import { WrappedMessageBlueprint } from "applesauce-common/blueprints/wrapped-message";
/**
* NIP-17 Adapter - Private Direct Messages
*
* Features:
* - End-to-end encrypted messaging using NIP-44
* - Gift-wrapped messages (NIP-59) for metadata privacy
* - Uses DM inbox relays (kind 10050) for delivery
* - Caches decrypted rumors to avoid repeated decryption
*
* Message flow:
* 1. Create Rumor (kind 14, unsigned)
* 2. Seal rumor (kind 13, encrypted to recipient)
* 3. Gift wrap seal (kind 1059, encrypted with ephemeral key)
* 4. Publish to recipient's DM inbox relays
*/
export class Nip17Adapter extends ChatProtocolAdapter {
readonly protocol = "nip-17" as const;
readonly type = "dm" as const;
/** Cache of DM inbox relays by pubkey */
private dmRelayCache = new Map<string, string[]>();
/**
* Parse identifier - accepts npub, nprofile, hex pubkey, or NIP-05
*/
parseIdentifier(input: string): ProtocolIdentifier | null {
// Try bech32 decoding (npub/nprofile)
try {
const decoded = nip19.decode(input);
if (decoded.type === "npub") {
return {
type: "dm-recipient",
value: decoded.data,
};
}
if (decoded.type === "nprofile") {
return {
type: "dm-recipient",
value: decoded.data.pubkey,
relays: decoded.data.relays,
};
}
} catch {
// Not bech32, try other formats
}
// Try hex pubkey
if (isValidHexPubkey(input)) {
return {
type: "dm-recipient",
value: input,
};
}
// Try NIP-05
if (isNip05(input)) {
return {
type: "chat-partner-nip05",
value: input,
};
}
return null;
}
/**
* Resolve conversation from identifier
*/
async resolveConversation(
identifier: ProtocolIdentifier,
): Promise<Conversation> {
let pubkey: string;
// Resolve NIP-05 if needed
if (identifier.type === "chat-partner-nip05") {
const resolved = await resolveNip05(identifier.value);
if (!resolved) {
throw new Error(`Failed to resolve NIP-05: ${identifier.value}`);
}
pubkey = resolved;
} else if (
identifier.type === "dm-recipient" ||
identifier.type === "chat-partner"
) {
pubkey = identifier.value;
} else {
throw new Error(
`NIP-17 adapter cannot handle identifier type: ${identifier.type}`,
);
}
const activePubkey = accountManager.active$.value?.pubkey;
if (!activePubkey) {
throw new Error("No active account");
}
console.log(
`[NIP-17] Resolving conversation with ${pubkey.slice(0, 8)}...`,
);
// Fetch DM relays for both parties in parallel
await Promise.all([
this.fetchDmRelays(activePubkey),
this.fetchDmRelays(pubkey),
]);
// Get display name for partner
const metadataEvent = await this.getMetadata(pubkey);
const metadata = metadataEvent
? getProfileContent(metadataEvent)
: undefined;
const title = getDisplayName(pubkey, metadata);
return {
id: `nip-17:${pubkey}`,
type: "dm",
protocol: "nip-17",
title,
participants: [
{ pubkey: activePubkey, role: "member" },
{ pubkey, role: "member" },
],
metadata: {
encrypted: true,
giftWrapped: true,
},
unreadCount: 0,
};
}
/**
* Fetch DM inbox relays (kind 10050) for a pubkey
*/
private async fetchDmRelays(pubkey: string): Promise<string[]> {
// Check cache first
const cached = this.dmRelayCache.get(pubkey);
if (cached) {
return cached;
}
console.log(`[NIP-17] Fetching DM relays for ${pubkey.slice(0, 8)}...`);
try {
// Try to load kind 10050 from relays
const event = await firstValueFrom(
addressLoader({ kind: 10050, pubkey, identifier: "" }).pipe(
catchError(() => of(null)),
),
{ defaultValue: null },
);
if (event) {
// Parse relay URLs from the event using getRelaysFromList
const relays = getRelaysFromList(event);
if (relays.length > 0) {
console.log(
`[NIP-17] Found ${relays.length} DM relays for ${pubkey.slice(0, 8)}`,
);
this.dmRelayCache.set(pubkey, relays);
return relays;
}
}
// Fallback: Try to get inbox relays from kind 10002 (NIP-65)
const relayListEvent = await firstValueFrom(
addressLoader({ kind: 10002, pubkey, identifier: "" }).pipe(
catchError(() => of(null)),
),
{ defaultValue: null },
);
if (relayListEvent) {
// Get inbox (read) relays as fallback
const { getInboxes } = await import("applesauce-core/helpers");
const inboxRelays = getInboxes(relayListEvent);
if (inboxRelays.length > 0) {
console.log(
`[NIP-17] Using ${inboxRelays.length} inbox relays as fallback for ${pubkey.slice(0, 8)}`,
);
this.dmRelayCache.set(pubkey, inboxRelays);
return inboxRelays;
}
}
// Final fallback: use aggregator relays
console.log(
`[NIP-17] No DM relays found for ${pubkey.slice(0, 8)}, using fallback`,
);
this.dmRelayCache.set(pubkey, AGGREGATOR_RELAYS);
return AGGREGATOR_RELAYS;
} catch (err) {
console.error(
`[NIP-17] Error fetching DM relays for ${pubkey.slice(0, 8)}:`,
err,
);
return AGGREGATOR_RELAYS;
}
}
/**
* Get DM relays for sending to a recipient
* Uses recipient's DM inbox relays
*/
private async getRecipientDmRelays(
recipientPubkey: string,
): Promise<string[]> {
return this.fetchDmRelays(recipientPubkey);
}
/**
* Get DM relays for receiving messages
* Uses our own DM inbox relays
*/
private async getOwnDmRelays(): Promise<string[]> {
const activePubkey = accountManager.active$.value?.pubkey;
if (!activePubkey) {
return AGGREGATOR_RELAYS;
}
return this.fetchDmRelays(activePubkey);
}
/**
* Load messages for a conversation
* Subscribes to gift wraps addressed to us, decrypts them, and filters by conversation
*/
loadMessages(
conversation: Conversation,
options?: LoadMessagesOptions,
): Observable<Message[]> {
const activePubkey = accountManager.active$.value?.pubkey;
const activeSigner = accountManager.active$.value?.signer;
if (!activePubkey || !activeSigner) {
throw new Error("No active account or signer");
}
const partner = conversation.participants.find(
(p) => p.pubkey !== activePubkey,
);
if (!partner) {
throw new Error("No conversation partner found");
}
console.log(
`[NIP-17] Loading messages for conversation with ${partner.pubkey.slice(0, 8)}...`,
);
// Start async process to set up subscription
const setupSubscription = async () => {
// Get DM relays for receiving
const dmRelays = await this.getOwnDmRelays();
console.log(`[NIP-17] Subscribing to ${dmRelays.length} DM relays`);
// Subscribe to kind 1059 (gift wraps) addressed to us
const filter: Filter = {
kinds: [1059],
"#p": [activePubkey],
limit: options?.limit || 100,
};
if (options?.before) {
filter.until = options.before;
}
if (options?.after) {
filter.since = options.after;
}
// Clean up any existing subscription for this conversation
this.cleanup(conversation.id);
// Start a persistent subscription to DM relays
const subscription = pool
.subscription(dmRelays, [filter], {
eventStore,
})
.subscribe({
next: (response) => {
if (typeof response === "string") {
console.log("[NIP-17] EOSE received");
} else {
console.log(
`[NIP-17] Received gift wrap: ${response.id.slice(0, 8)}...`,
);
}
},
});
// Store subscription for cleanup
this.subscriptions.set(conversation.id, subscription);
};
// Start subscription setup
setupSubscription().catch((err) => {
console.error("[NIP-17] Error setting up subscription:", err);
});
// Return observable from EventStore that decrypts gift wraps
const giftWrapFilter: Filter = {
kinds: [1059],
"#p": [activePubkey],
};
return eventStore.timeline(giftWrapFilter).pipe(
switchMap((giftWraps) => {
// Decrypt all gift wraps and filter for this conversation
return from(
this.decryptAndFilterMessages(
giftWraps,
activePubkey,
partner.pubkey,
conversation.id,
),
);
}),
map((messages) => {
console.log(
`[NIP-17] Decrypted ${messages.length} messages for conversation`,
);
// Sort by timestamp ascending
return messages.sort((a, b) => a.timestamp - b.timestamp);
}),
);
}
/**
* Decrypt gift wraps and filter messages for a specific conversation
*/
private async decryptAndFilterMessages(
giftWraps: NostrEvent[],
selfPubkey: string,
partnerPubkey: string,
conversationId: string,
): Promise<Message[]> {
const signer = accountManager.active$.value?.signer;
if (!signer) {
console.error("[NIP-17] No signer available for decryption");
return [];
}
const messages: Message[] = [];
const expectedParticipants = [selfPubkey, partnerPubkey].sort().join(":");
for (const giftWrap of giftWraps) {
try {
let rumor: Rumor | undefined;
// Check if already unlocked (cached)
if (isGiftWrapUnlocked(giftWrap)) {
rumor = getGiftWrapRumor(giftWrap);
} else {
// Decrypt - this will cache the result
rumor = await unlockGiftWrap(giftWrap, signer);
}
if (!rumor) {
continue;
}
// Only process kind 14 (NIP-17 direct messages)
if (rumor.kind !== 14) {
continue;
}
// Check if this message belongs to this conversation
const messageParticipants = getConversationParticipants(rumor);
const participantKey = messageParticipants.sort().join(":");
if (participantKey !== expectedParticipants) {
// Message is for a different conversation
continue;
}
// Convert rumor to message
const message = this.rumorToMessage(rumor, conversationId, giftWrap);
messages.push(message);
} catch (err) {
// Decryption failed - might not be for us or corrupted
console.debug(
`[NIP-17] Failed to decrypt gift wrap ${giftWrap.id.slice(0, 8)}:`,
err,
);
}
}
return messages;
}
/**
* Load more historical messages (pagination)
*/
async loadMoreMessages(
conversation: Conversation,
before: number,
): Promise<Message[]> {
const activePubkey = accountManager.active$.value?.pubkey;
const activeSigner = accountManager.active$.value?.signer;
if (!activePubkey || !activeSigner) {
throw new Error("No active account or signer");
}
const partner = conversation.participants.find(
(p) => p.pubkey !== activePubkey,
);
if (!partner) {
throw new Error("No conversation partner found");
}
console.log(
`[NIP-17] Loading older messages before ${before} for ${partner.pubkey.slice(0, 8)}`,
);
// Get DM relays
const dmRelays = await this.getOwnDmRelays();
// Fetch older gift wraps
const filter: Filter = {
kinds: [1059],
"#p": [activePubkey],
until: before,
limit: 50,
};
const giftWraps: NostrEvent[] = [];
await new Promise<void>((resolve) => {
const timeout = setTimeout(() => {
console.log("[NIP-17] Pagination fetch timeout");
resolve();
}, 10000);
const obs = pool.subscription(dmRelays, [filter], { eventStore });
const sub = obs.subscribe({
next: (response) => {
if (typeof response === "string") {
clearTimeout(timeout);
sub.unsubscribe();
resolve();
} else {
giftWraps.push(response);
}
},
error: (err) => {
clearTimeout(timeout);
console.error("[NIP-17] Pagination fetch error:", err);
sub.unsubscribe();
resolve();
},
});
});
console.log(`[NIP-17] Fetched ${giftWraps.length} older gift wraps`);
// Decrypt and filter
const messages = await this.decryptAndFilterMessages(
giftWraps,
activePubkey,
partner.pubkey,
conversation.id,
);
return messages.sort((a, b) => a.timestamp - b.timestamp);
}
/**
* Send a message
*/
async sendMessage(
conversation: Conversation,
content: string,
options?: SendMessageOptions,
): Promise<void> {
const activePubkey = accountManager.active$.value?.pubkey;
const activeSigner = accountManager.active$.value?.signer;
if (!activePubkey || !activeSigner) {
throw new Error("No active account or signer");
}
const partner = conversation.participants.find(
(p) => p.pubkey !== activePubkey,
);
if (!partner) {
throw new Error("No conversation partner found");
}
console.log(`[NIP-17] Sending message to ${partner.pubkey.slice(0, 8)}...`);
// Create event factory and sign
const factory = new EventFactory();
factory.setSigner(activeSigner);
// Build the wrapped message rumor using applesauce blueprint
const participants = [activePubkey, partner.pubkey];
// Create the message rumor using factory.create with blueprint
let rumor = await factory.create(
WrappedMessageBlueprint,
participants,
content,
);
// Add reply tag if replying
if (options?.replyTo) {
rumor = {
...rumor,
tags: [...rumor.tags, ["e", options.replyTo, "", "reply"]],
};
}
// Add emoji tags
if (options?.emojiTags) {
const emojiTags = options.emojiTags.map((e) => [
"emoji",
e.shortcode,
e.url,
]);
rumor = {
...rumor,
tags: [...rumor.tags, ...emojiTags],
};
}
// Get recipient's DM relays
const recipientRelays = await this.getRecipientDmRelays(partner.pubkey);
const ownRelays = await this.getOwnDmRelays();
// Send gift-wrapped copy to recipient
const recipientGiftWrap = await factory.create(
GiftWrapBlueprint,
partner.pubkey,
rumor,
);
const signedRecipientWrap = await factory.sign(recipientGiftWrap);
console.log(
`[NIP-17] Publishing to ${recipientRelays.length} recipient relays`,
);
await publishEventToRelays(signedRecipientWrap, recipientRelays);
// Send gift-wrapped copy to ourselves (for syncing across devices)
const selfGiftWrap = await factory.create(
GiftWrapBlueprint,
activePubkey,
rumor,
);
const signedSelfWrap = await factory.sign(selfGiftWrap);
console.log(`[NIP-17] Publishing self-copy to ${ownRelays.length} relays`);
await publishEventToRelays(signedSelfWrap, ownRelays);
console.log("[NIP-17] Message sent successfully");
}
/**
* Get protocol capabilities
*/
getCapabilities(): ChatCapabilities {
return {
supportsEncryption: true,
supportsThreading: true, // e-tag replies
supportsModeration: false,
supportsRoles: false,
supportsGroupManagement: false,
canCreateConversations: true,
requiresRelay: false,
};
}
/**
* Load a replied-to message
* For NIP-17, we need to search through decrypted rumors
*/
async loadReplyMessage(
_conversation: Conversation,
eventId: string,
): Promise<NostrEvent | null> {
const activePubkey = accountManager.active$.value?.pubkey;
const activeSigner = accountManager.active$.value?.signer;
if (!activePubkey || !activeSigner) {
return null;
}
// First check if we have this event in our decrypted messages
// The eventId for NIP-17 refers to the rumor ID, not the gift wrap ID
const giftWrapFilter: Filter = {
kinds: [1059],
"#p": [activePubkey],
};
// Get all gift wraps from store
const giftWraps = await firstValueFrom(
eventStore.timeline(giftWrapFilter).pipe(first()),
{ defaultValue: [] as NostrEvent[] },
);
// Try to find the rumor with matching ID
for (const giftWrap of giftWraps) {
try {
let rumor: Rumor | undefined;
if (isGiftWrapUnlocked(giftWrap)) {
rumor = getGiftWrapRumor(giftWrap);
} else {
rumor = await unlockGiftWrap(giftWrap, activeSigner);
}
if (rumor && rumor.id === eventId) {
// Found it - return as NostrEvent (rumors are structurally similar)
return rumor as unknown as NostrEvent;
}
} catch {
// Decryption failed, continue
}
}
console.log(
`[NIP-17] Reply message ${eventId.slice(0, 8)} not found in decrypted messages`,
);
return null;
}
/**
* Helper: Convert Rumor to Message
*/
private rumorToMessage(
rumor: Rumor,
conversationId: string,
giftWrap: NostrEvent,
): Message {
const sender = getWrappedMessageSender(rumor);
const replyTo = getWrappedMessageParent(rumor);
// Also check for e-tag replies
const eTags = getTagValues(rumor as unknown as NostrEvent, "e");
const eTagReply = eTags.find((_, i, arr) => {
// Look for e-tag with "reply" marker
const tag = rumor.tags.find(
(t) => t[0] === "e" && t[1] === arr[i] && t[3] === "reply",
);
return !!tag;
});
return {
id: rumor.id,
conversationId,
author: sender,
content: rumor.content,
timestamp: rumor.created_at,
type: "user",
replyTo: replyTo || eTagReply,
protocol: "nip-17",
metadata: {
encrypted: true,
},
// Store the gift wrap as the "event" for reference
// (the actual rumor is unsigned so we reference the wrapper)
event: giftWrap,
};
}
/**
* Helper: Get user metadata
*/
private async getMetadata(pubkey: string): Promise<NostrEvent | undefined> {
return firstValueFrom(eventStore.replaceable(0, pubkey), {
defaultValue: undefined,
});
}
}