feat: Add read-only NIP-17 chat adapter for private DMs

- Create NIP-17 chat adapter supporting npub, nprofile, hex pubkey, NIP-05
- Support self-messages ("Saved Messages"), groups, and 1-on-1 conversations
- Add gift wrap event persistence to Dexie (version 17)
- Load stored gift wraps into EventStore on startup
- Export Rumor type from gift-wrap service for adapter use
- Wire up NIP-17 adapter to chat parser and ChatViewer
- Add kind 14 to CHAT_KINDS for consistent message filtering
- Update tests to reflect NIP-17 support for npub identifiers

Chat command formats now supported:
- chat npub1.../nprofile1.../hex pubkey (single recipient)
- chat user@example.com (NIP-05 resolution)
- chat npub1...,npub2... (group conversation)
This commit is contained in:
Claude
2026-01-16 10:00:00 +00:00
parent e04d691f1f
commit 6dca82d658
7 changed files with 555 additions and 15 deletions

View File

@@ -25,6 +25,7 @@ import type {
} from "@/types/chat";
import { CHAT_KINDS } 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";
@@ -1046,10 +1047,10 @@ function getAdapter(protocol: ChatProtocol): ChatProtocolAdapter {
switch (protocol) {
// case "nip-c7": // Phase 1 - Simple chat (coming soon)
// 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)
// return new Nip28Adapter();
case "nip-53":

View File

@@ -89,10 +89,15 @@ describe("parseChatCommand", () => {
);
});
it("should throw error for npub (NIP-C7 disabled)", () => {
expect(() => parseChatCommand(["npub1xyz"])).toThrow(
/Unable to determine chat protocol/,
);
it("should parse npub as NIP-17 private DM", () => {
// Valid npub format (64 hex chars encoded)
const result = parseChatCommand([
"npub180cvv07tjdrrgpa0j7j7tmnyl2yr6yr7l8j4s3evf6u64th6gkwsyjh6w6",
]);
expect(result.protocol).toBe("nip-17");
expect(result.identifier.type).toBe("dm-recipient");
expect(result.adapter.protocol).toBe("nip-17");
});
it("should throw error for note/nevent (NIP-28 not implemented)", () => {

View File

@@ -1,10 +1,10 @@
import type { ChatCommandResult, GroupListIdentifier } 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 { nip19 } from "nostr-tools";
// Import other adapters as they're implemented
// import { Nip17Adapter } from "./chat/adapters/nip-17-adapter";
// import { Nip28Adapter } from "./chat/adapters/nip-28-adapter";
/**
@@ -62,10 +62,10 @@ 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 Nip17Adapter(), // Private DMs (gift-wrapped)
// new Nip28Adapter(), // Phase 3 - Public channels
new Nip29Adapter(), // Relay groups
new Nip53Adapter(), // Live activity chat
// new NipC7Adapter(), // Phase 1 - Simple chat (disabled for now)
];
@@ -84,6 +84,12 @@ export function parseChatCommand(args: string[]): ChatCommandResult {
`Unable to determine chat protocol from identifier: ${identifier}
Currently supported formats:
- npub1.../nprofile1.../hex pubkey (NIP-17 private DMs)
Examples:
chat npub1abc...
chat nprofile1...
chat user@example.com (NIP-05)
chat npub1...,npub2...,npub3... (group chat)
- relay.com'group-id (NIP-29 relay group, wss:// prefix optional)
Examples:
chat relay.example.com'bitcoin-dev
@@ -99,7 +105,6 @@ Currently supported formats:
chat naddr1... (group list address)
More formats coming soon:
- npub/nprofile/hex pubkey (NIP-C7/NIP-17 direct messages)
- note/nevent (NIP-28 public channels)`,
);
}

View File

@@ -0,0 +1,436 @@
import { Observable, of } from "rxjs";
import { map } from "rxjs/operators";
import { nip19 } from "nostr-tools";
import { ChatProtocolAdapter, type SendMessageOptions } from "./base-adapter";
import type {
Conversation,
Message,
ProtocolIdentifier,
ChatCapabilities,
LoadMessagesOptions,
Participant,
} from "@/types/chat";
import type { NostrEvent } from "@/types/nostr";
import giftWrapService, { type Rumor } from "@/services/gift-wrap";
import accountManager from "@/services/accounts";
import { resolveNip05 } from "@/lib/nip05";
/** Kind 14: Private direct message (NIP-17) */
const PRIVATE_DM_KIND = 14;
/**
* Compute a stable conversation ID from sorted participant pubkeys
*/
function computeConversationId(participants: string[]): string {
const sorted = [...participants].sort();
return `nip17:${sorted.join(",")}`;
}
/**
* Parse participants from a comma-separated list or single identifier
* Supports: npub, nprofile, hex pubkey (32 bytes), NIP-05
*/
async function parseParticipants(input: string): Promise<string[]> {
const parts = input
.split(",")
.map((p) => p.trim())
.filter(Boolean);
const pubkeys: string[] = [];
for (const part of parts) {
const pubkey = await resolveToPubkey(part);
if (pubkey && !pubkeys.includes(pubkey)) {
pubkeys.push(pubkey);
}
}
return pubkeys;
}
/**
* Resolve an identifier to a hex pubkey
*/
async function resolveToPubkey(input: string): Promise<string | null> {
// Try npub
if (input.startsWith("npub1")) {
try {
const decoded = nip19.decode(input);
if (decoded.type === "npub") {
return decoded.data;
}
} catch {
// Not a valid npub
}
}
// Try nprofile
if (input.startsWith("nprofile1")) {
try {
const decoded = nip19.decode(input);
if (decoded.type === "nprofile") {
return decoded.data.pubkey;
}
} catch {
// Not a valid nprofile
}
}
// Try hex pubkey (64 chars)
if (/^[0-9a-fA-F]{64}$/.test(input)) {
return input.toLowerCase();
}
// Try NIP-05 (contains @ or is bare domain)
if (input.includes("@") || input.includes(".")) {
try {
const pubkey = await resolveNip05(input);
if (pubkey) {
return pubkey;
}
} catch {
// NIP-05 resolution failed
}
}
return null;
}
/**
* NIP-17 Adapter - Private Direct Messages (Gift Wrapped)
*
* Features:
* - End-to-end encrypted messages via NIP-59 gift wraps
* - 1-on-1 conversations
* - Group conversations (multiple recipients)
* - Self-messages ("saved messages")
* - Read-only for now (sending messages coming later)
*
* Identifier formats:
* - npub1... (single recipient)
* - nprofile1... (single recipient with relay hints)
* - hex pubkey (64 chars)
* - NIP-05 address (user@domain.com or _@domain.com)
* - Comma-separated list of any of the above for groups
*/
export class Nip17Adapter extends ChatProtocolAdapter {
readonly protocol = "nip-17" as const;
readonly type = "dm" as const;
/**
* Parse identifier - accepts pubkeys, npubs, nprofiles, NIP-05, or comma-separated list
*/
parseIdentifier(input: string): ProtocolIdentifier | null {
// Quick check: must look like a pubkey identifier or NIP-05
const trimmed = input.trim();
// Check for npub, nprofile, hex, or NIP-05 patterns
const looksLikePubkey =
trimmed.startsWith("npub1") ||
trimmed.startsWith("nprofile1") ||
/^[0-9a-fA-F]{64}$/.test(trimmed) ||
trimmed.includes("@") ||
(trimmed.includes(".") &&
!trimmed.includes("'") &&
!trimmed.includes("/"));
// Also check for comma-separated list
const looksLikeList =
trimmed.includes(",") &&
trimmed
.split(",")
.some(
(p) =>
p.trim().startsWith("npub1") ||
p.trim().startsWith("nprofile1") ||
/^[0-9a-fA-F]{64}$/.test(p.trim()) ||
p.trim().includes("@"),
);
if (!looksLikePubkey && !looksLikeList) {
return null;
}
// Return a placeholder identifier - actual resolution happens in resolveConversation
return {
type: "dm-recipient",
value: trimmed, // Will be resolved later
relays: [],
};
}
/**
* Resolve conversation from DM identifier
*/
async resolveConversation(
identifier: ProtocolIdentifier,
): Promise<Conversation> {
if (
identifier.type !== "dm-recipient" &&
identifier.type !== "chat-partner"
) {
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");
}
// Check if private messages are enabled
const settings = giftWrapService.settings$.value;
if (!settings.enabled) {
throw new Error(
"Private messages are not enabled. Enable them in the inbox settings.",
);
}
// Parse the identifier to get participant pubkeys
const inputPubkeys = await parseParticipants(identifier.value);
if (inputPubkeys.length === 0) {
throw new Error(
`Could not resolve any pubkeys from: ${identifier.value}`,
);
}
// Build full participant list (always include self)
const allParticipants = [
activePubkey,
...inputPubkeys.filter((p) => p !== activePubkey),
];
const uniqueParticipants = [...new Set(allParticipants)];
// Determine conversation type
const isSelfChat = uniqueParticipants.length === 1; // Only self
const isGroup = uniqueParticipants.length > 2; // More than 2 people
// Create conversation ID from participants
const conversationId = computeConversationId(uniqueParticipants);
// Build title
let title: string;
if (isSelfChat) {
title = "Saved Messages";
} else if (isGroup) {
title = `Group (${uniqueParticipants.length})`;
} else {
// 1-on-1: use the other person's pubkey for title
const otherPubkey = uniqueParticipants.find((p) => p !== activePubkey);
title = otherPubkey ? `${otherPubkey.slice(0, 8)}...` : "Private Chat";
}
// Build participants array
const participants: Participant[] = uniqueParticipants.map((pubkey) => ({
pubkey,
role: pubkey === activePubkey ? "member" : undefined,
}));
return {
id: conversationId,
type: "dm",
protocol: "nip-17",
title,
participants,
metadata: {
encrypted: true,
giftWrapped: true,
},
unreadCount: 0,
};
}
/**
* Load messages for a conversation
* Filters decrypted rumors to match conversation participants
*/
loadMessages(
conversation: Conversation,
_options?: LoadMessagesOptions,
): Observable<Message[]> {
const participantSet = new Set(
conversation.participants.map((p) => p.pubkey),
);
return giftWrapService.decryptedRumors$.pipe(
map((rumors) => {
// Filter rumors that belong to this conversation
const conversationRumors = rumors.filter(({ rumor }) => {
// Only include kind 14 (private DMs)
if (rumor.kind !== PRIVATE_DM_KIND) return false;
// Get all participants from the rumor
const rumorParticipants = this.getRumorParticipants(rumor);
// Check if participants match (same set of pubkeys)
if (rumorParticipants.size !== participantSet.size) return false;
for (const p of rumorParticipants) {
if (!participantSet.has(p)) return false;
}
return true;
});
// Convert to Message format
return conversationRumors.map(({ giftWrap, rumor }) =>
this.rumorToMessage(conversation.id, giftWrap, rumor),
);
}),
);
}
/**
* Get all participants from a rumor (author + all p-tag recipients)
*/
private getRumorParticipants(rumor: Rumor): Set<string> {
const participants = new Set<string>();
participants.add(rumor.pubkey); // Author
// Add all p-tag recipients
for (const tag of rumor.tags) {
if (tag[0] === "p" && tag[1]) {
participants.add(tag[1]);
}
}
return participants;
}
/**
* Convert a rumor to a Message
*/
private rumorToMessage(
conversationId: string,
giftWrap: NostrEvent,
rumor: Rumor,
): Message {
// Find reply-to from e tags
let replyTo: string | undefined;
for (const tag of rumor.tags) {
if (tag[0] === "e" && tag[1]) {
// NIP-10: last e tag is usually the reply target
replyTo = tag[1];
}
}
return {
id: rumor.id,
conversationId,
author: rumor.pubkey,
content: rumor.content,
timestamp: rumor.created_at,
type: "user",
replyTo,
metadata: {
encrypted: true,
},
protocol: "nip-17",
// Use gift wrap as the event since rumor is unsigned
event: giftWrap,
};
}
/**
* Load more historical messages (pagination)
*/
async loadMoreMessages(
_conversation: Conversation,
_before: number,
): Promise<Message[]> {
// For now, all messages are loaded at once from the gift wrap service
// Pagination would require fetching more gift wraps from relays
return [];
}
/**
* Send a message (not implemented yet - read-only for now)
*/
async sendMessage(
_conversation: Conversation,
_content: string,
_options?: SendMessageOptions,
): Promise<void> {
throw new Error(
"Sending messages is not yet implemented for NIP-17. Coming soon!",
);
}
/**
* Get capabilities
*/
getCapabilities(): ChatCapabilities {
return {
supportsEncryption: true,
supportsThreading: true, // via e tags
supportsModeration: false,
supportsRoles: false,
supportsGroupManagement: false,
canCreateConversations: false, // read-only for now
requiresRelay: false, // uses inbox relays from profile
};
}
/**
* Load a replied-to message by ID
*/
async loadReplyMessage(
_conversation: Conversation,
eventId: string,
): Promise<NostrEvent | null> {
// Check decrypted rumors for the message
const rumors = giftWrapService.decryptedRumors$.value;
const found = rumors.find(({ rumor }) => rumor.id === eventId);
if (found) {
// Return the gift wrap event
return found.giftWrap;
}
return null;
}
/**
* Load conversation list from gift wrap service
*/
loadConversationList(): Observable<Conversation[]> {
const activePubkey = accountManager.active$.value?.pubkey;
if (!activePubkey) {
return of([]);
}
return giftWrapService.conversations$.pipe(
map((conversations) =>
conversations.map((conv) => ({
id: conv.id,
type: "dm" as const,
protocol: "nip-17" as const,
title: this.getConversationTitle(conv.participants, activePubkey),
participants: conv.participants.map((pubkey) => ({ pubkey })),
metadata: {
encrypted: true,
giftWrapped: true,
},
lastMessage: conv.lastMessage
? this.rumorToMessage(conv.id, conv.lastGiftWrap!, conv.lastMessage)
: undefined,
unreadCount: 0,
})),
),
);
}
/**
* Get conversation title from participants
*/
private getConversationTitle(
participants: string[],
activePubkey: string,
): string {
const others = participants.filter((p) => p !== activePubkey);
if (others.length === 0) {
return "Saved Messages";
} else if (others.length === 1) {
return `${others[0].slice(0, 8)}...`;
} else {
return `Group (${participants.length})`;
}
}
}

View File

@@ -67,6 +67,13 @@ export interface EncryptedContentEntry {
savedAt: number;
}
export interface StoredGiftWrap {
id: string; // Event ID
event: NostrEvent; // Full gift wrap event
userPubkey: string; // Recipient pubkey (from #p tag)
savedAt: number;
}
/**
* Get all stored encrypted content IDs
* Used to check which gift wraps have already been decrypted
@@ -76,6 +83,36 @@ export async function getStoredEncryptedContentIds(): Promise<Set<string>> {
return new Set(entries.map((e) => e.id));
}
/**
* Save gift wrap events to Dexie for persistence across sessions
*/
export async function saveGiftWraps(
events: NostrEvent[],
userPubkey: string,
): Promise<void> {
const entries: StoredGiftWrap[] = events.map((event) => ({
id: event.id,
event,
userPubkey,
savedAt: Date.now(),
}));
await db.giftWraps.bulkPut(entries);
}
/**
* Load stored gift wrap events for a user
*/
export async function loadStoredGiftWraps(
userPubkey: string,
): Promise<NostrEvent[]> {
const entries = await db.giftWraps
.where("userPubkey")
.equals(userPubkey)
.toArray();
return entries.map((e) => e.event);
}
export interface LocalSpell {
id: string; // UUID for local-only spells, or event ID for published spells
alias?: string; // Optional local-only quick name (e.g., "btc")
@@ -114,6 +151,7 @@ class GrimoireDb extends Dexie {
spells!: Table<LocalSpell>;
spellbooks!: Table<LocalSpellbook>;
encryptedContent!: Table<EncryptedContentEntry>;
giftWraps!: Table<StoredGiftWrap>;
constructor(name: string) {
super(name);
@@ -364,6 +402,22 @@ class GrimoireDb extends Dexie {
spellbooks: "&id, slug, title, createdAt, isPublished, deletedAt",
encryptedContent: "&id, savedAt",
});
// Version 17: Add gift wrap event storage for NIP-17 chat
this.version(17).stores({
profiles: "&pubkey",
nip05: "&nip05",
nips: "&id",
relayInfo: "&url",
relayAuthPreferences: "&url",
relayLists: "&pubkey, updatedAt",
relayLiveness: "&url",
blossomServers: "&pubkey, updatedAt",
spells: "&id, alias, createdAt, isPublished, deletedAt",
spellbooks: "&id, slug, title, createdAt, isPublished, deletedAt",
encryptedContent: "&id, savedAt",
giftWraps: "&id, userPubkey, savedAt",
});
}
}

View File

@@ -14,7 +14,12 @@ import type { NostrEvent } from "@/types/nostr";
import type { ISigner } from "applesauce-signers";
import eventStore from "./event-store";
import pool from "./relay-pool";
import { encryptedContentStorage, getStoredEncryptedContentIds } from "./db";
import {
encryptedContentStorage,
getStoredEncryptedContentIds,
saveGiftWraps,
loadStoredGiftWraps,
} from "./db";
import { AGGREGATOR_RELAYS } from "./loaders";
import relayListCache from "./relay-list-cache";
@@ -25,7 +30,7 @@ const DM_RELAY_LIST_KIND = 10050;
const PRIVATE_DM_KIND = 14;
/** Rumor is an unsigned event - used for gift wrap contents */
interface Rumor {
export interface Rumor {
id: string;
pubkey: string;
created_at: number;
@@ -177,12 +182,33 @@ class GiftWrapService {
});
this.subscriptions.push(updateSub);
// If enabled, start syncing
// If enabled, load stored gift wraps and start syncing
if (this.settings$.value.enabled) {
await this.loadStoredGiftWraps();
this.startSync();
}
}
/** Load stored gift wraps from Dexie into EventStore */
private async loadStoredGiftWraps() {
if (!this.userPubkey) return;
try {
const storedEvents = await loadStoredGiftWraps(this.userPubkey);
if (storedEvents.length > 0) {
console.log(
`[GiftWrap] Loading ${storedEvents.length} stored gift wraps into EventStore`,
);
// Add stored events to EventStore - this triggers the timeline subscription
for (const event of storedEvents) {
eventStore.add(event);
}
}
} catch (err) {
console.warn(`[GiftWrap] Error loading stored gift wraps:`, err);
}
}
/** Update settings */
updateSettings(settings: Partial<InboxSettings>) {
const newSettings = { ...this.settings$.value, ...settings };
@@ -321,6 +347,11 @@ class GiftWrapService {
.timeline(reqFilter)
.pipe(map((events) => events.sort((a, b) => b.created_at - a.created_at)))
.subscribe((giftWraps) => {
// Find new gift wraps that we haven't seen before
const newGiftWraps = giftWraps.filter(
(gw) => !this.giftWraps.some((existing) => existing.id === gw.id),
);
this.giftWraps = giftWraps;
this.giftWraps$.next(giftWraps);
@@ -340,6 +371,13 @@ class GiftWrapService {
this.decryptStates$.next(new Map(this.decryptStates));
this.updatePendingCount();
// Persist new gift wraps to Dexie for fast loading on next startup
if (newGiftWraps.length > 0 && this.userPubkey) {
saveGiftWraps(newGiftWraps, this.userPubkey).catch((err) => {
console.warn(`[GiftWrap] Error saving gift wraps:`, err);
});
}
// Update conversations
this.updateConversations();

View File

@@ -6,6 +6,7 @@ import type { NostrEvent } from "./nostr";
*/
export const CHAT_KINDS = [
9, // NIP-29: Group chat messages
14, // NIP-17: Private direct messages (inside gift wrap)
9321, // NIP-61: Nutzaps (ecash zaps in groups/live chats)
1311, // NIP-53: Live chat messages
9735, // NIP-57: Zap receipts (part of chat context)