wip: Send dm on sigit sign/complete #230

Draft
enes wants to merge 3 commits from 92-send-completion-dm into staging
5 changed files with 284 additions and 13 deletions

View File

@ -39,7 +39,9 @@ import {
updateUsersAppData, updateUsersAppData,
uploadToFileStorage, uploadToFileStorage,
DEFAULT_TOOLBOX, DEFAULT_TOOLBOX,
settleAllFullfilfedPromises settleAllFullfilfedPromises,
sendPrivateDirectMessage,
parseNostrEvent
} from '../../utils' } from '../../utils'
import { Container } from '../../components/Container' import { Container } from '../../components/Container'
import fileListStyles from '../../components/FileList/style.module.scss' import fileListStyles from '../../components/FileList/style.module.scss'
@ -62,6 +64,7 @@ import {
} from '@fortawesome/free-solid-svg-icons' } from '@fortawesome/free-solid-svg-icons'
import { getSigitFile, SigitFile } from '../../utils/file.ts' import { getSigitFile, SigitFile } from '../../utils/file.ts'
import _ from 'lodash' import _ from 'lodash'
import { SendDMError } from '../../types/errors/SendDMError.ts'
export const CreatePage = () => { export const CreatePage = () => {
const navigate = useNavigate() const navigate = useNavigate()
@ -713,6 +716,31 @@ export const CreatePage = () => {
toast.error('Failed to publish notifications') toast.error('Failed to publish notifications')
}) })
// Send DM to the next signer
setLoadingSpinnerDesc('Sending DMs')
if (signers.length > 0 && signers[0].pubkey !== usersPubkey) {
// No need to send notification to self so remove it from the list
const nextSigner = signers[0].pubkey
if (nextSigner) {
const createSignatureEvent = await parseNostrEvent(
meta.createSignature
)
const { id } = createSignatureEvent
try {
await sendPrivateDirectMessage(
`Sigit created, visit ${window.location.origin}/#/sign/${id}`,
npubToHex(nextSigner)!
)
} catch (error) {
if (error instanceof SendDMError) {
toast.error(error.message)
}
console.error(error)
}
}
}
navigate(appPrivateRoutes.sign, { state: { meta } }) navigate(appPrivateRoutes.sign, { state: { meta } })
} else { } else {
const zip = new JSZip() const zip = new JSZip()

View File

@ -33,7 +33,9 @@ import {
signEventForMetaFile, signEventForMetaFile,
updateUsersAppData, updateUsersAppData,
findOtherUserMarks, findOtherUserMarks,
timeout timeout,
sendPrivateDirectMessage,
parseNostrEvent
} from '../../utils' } from '../../utils'
import { Container } from '../../components/Container' import { Container } from '../../components/Container'
import { DisplayMeta } from './internal/displayMeta' import { DisplayMeta } from './internal/displayMeta'
@ -53,6 +55,7 @@ import {
SigitFile SigitFile
} from '../../utils/file.ts' } from '../../utils/file.ts'
import { ARRAY_BUFFER, DEFLATE } from '../../utils/const.ts' import { ARRAY_BUFFER, DEFLATE } from '../../utils/const.ts'
import { SendDMError } from '../../types/errors/SendDMError.ts'
enum SignedStatus { enum SignedStatus {
Fully_Signed, Fully_Signed,
@ -720,6 +723,65 @@ export const SignPage = () => {
toast.error('Failed to publish notifications') toast.error('Failed to publish notifications')
}) })
// Send DMs
setLoadingSpinnerDesc('Sending DMs')
const createSignatureEvent = await parseNostrEvent(meta.createSignature)
const { id } = createSignatureEvent
if (isLastSigner) {
// Final sign sends to everyone (creator, signers, viewers - /verify)
const areSent: boolean[] = Array(users.length).fill(false)
for (let i = 0; i < users.length; i++) {
try {
areSent[i] = await sendPrivateDirectMessage(
`Sigit completed, visit ${window.location.origin}/#/verify/${id}`,
npubToHex(users[i])!
)
} catch (error) {
if (error instanceof SendDMError) {
toast.error(error.message)
}
console.error(error)
}
}
if (areSent.some((r) => r)) {
toast.success(
`DMs sent ${areSent.filter((r) => r).length}/${users.length}`
)
}
} else {
// Notify the creator and the next signer (/sign).
try {
await sendPrivateDirectMessage(
`Sigit signed by ${usersNpub}, visit ${window.location.origin}/#/sign/${id}`,
npubToHex(submittedBy!)!
)
} catch (error) {
if (error instanceof SendDMError) {
toast.error(error.message)
}
console.error(error)
}
// No need to notify creator twice, skipping
const currentSignerIndex = signers.indexOf(usersNpub)
const nextSigner = signers[currentSignerIndex + 1]
if (nextSigner !== submittedBy) {
try {
await sendPrivateDirectMessage(
`You're the next signer, visit ${window.location.origin}/#/sign/${id}`,
npubToHex(nextSigner)!
)
} catch (error) {
if (error instanceof SendDMError) {
toast.error(error.message)
}
console.error(error)
}
}
}
setIsLoading(false) setIsLoading(false)
} }

View File

@ -0,0 +1,24 @@
import { Jsonable } from '.'
export enum SendDMErrorType {
'METADATA_FETCH_FAILED' = 'Sending DM failed. An error occured while fetching user metadata.',
'RELAY_READ_EMPTY' = `Sending DM failed. The user's relay read set is empty.`,
'ENCRYPTION_FAILED' = 'Sending DM failed. An error occurred in encrypting dm message.',
'RELAY_PUBLISH_FAILED' = 'Sending DM failed. Publishing events failed.'
}
export class SendDMError extends Error {
public readonly context?: Jsonable
constructor(
message: string,
options: { cause?: Error; context?: Jsonable } = {}
) {
const { cause, context } = options
super(message, { cause })
this.name = this.constructor.name
this.context = context
}
}

View File

@ -6,6 +6,7 @@ import {
EventTemplate, EventTemplate,
Filter, Filter,
UnsignedEvent, UnsignedEvent,
VerifiedEvent,
finalizeEvent, finalizeEvent,
generateSecretKey, generateSecretKey,
getEventHash, getEventHash,
@ -21,7 +22,8 @@ import { NIP05_REGEX } from '../constants'
import { import {
MetadataController, MetadataController,
NostrController, NostrController,
relayController relayController,
RelayController
} from '../controllers' } from '../controllers'
import { import {
updateProcessedGiftWraps, updateProcessedGiftWraps,
@ -35,6 +37,7 @@ import { parseJson, removeLeadingSlash } from './string'
import { timeout } from './utils' import { timeout } from './utils'
import { getHash } from './hash' import { getHash } from './hash'
import { SIGIT_BLOSSOM } from './const.ts' import { SIGIT_BLOSSOM } from './const.ts'
import { SendDMError, SendDMErrorType } from '../types/errors/SendDMError.ts'
/** /**
* Generates a `d` tag for userAppData * Generates a `d` tag for userAppData
@ -248,6 +251,12 @@ export const toUnixTimestamp = (date: number | Date) => {
export const fromUnixTimestamp = (unix: number) => { export const fromUnixTimestamp = (unix: number) => {
return unix * 1000 return unix * 1000
} }
export const randomTimeUpTo2DaysInThePast = (): number => {
const now = Date.now()
const twoDaysInMilliseconds = 2 * 24 * 60 * 60 * 1000
const randomPastTime = now - Math.floor(Math.random() * twoDaysInMilliseconds)
return toUnixTimestamp(randomPastTime)
}
/** /**
* Generate nip44 conversation key * Generate nip44 conversation key
@ -297,19 +306,21 @@ export const countLeadingZeroes = (hex: string) => {
/** /**
* Function to create a wrapped event with PoW * Function to create a wrapped event with PoW
* @param event Original event to be wrapped * @param event Original event to be wrapped (can be unsigned or verified)
* @param receiver Public key of the receiver * @param receiver Public key of the receiver
* @param difficulty PoW difficulty level (default is 20)
* @returns * @returns
*/ */
// //
export const createWrap = (unsignedEvent: UnsignedEvent, receiver: string) => { export const createWrap = (
event: UnsignedEvent | VerifiedEvent,
receiver: string
) => {
// Generate a random secret key and its corresponding public key // Generate a random secret key and its corresponding public key
const randomKey = generateSecretKey() const randomKey = generateSecretKey()
const pubkey = getPublicKey(randomKey) const pubkey = getPublicKey(randomKey)
// Encrypt the event content using nip44 encryption // Encrypt the event content using nip44 encryption
const content = nip44Encrypt(unsignedEvent, randomKey, receiver) const content = nip44Encrypt(event, randomKey, receiver)
// Initialize nonce and leadingZeroes for PoW calculation // Initialize nonce and leadingZeroes for PoW calculation
let nonce = 0 let nonce = 0
@ -320,11 +331,12 @@ export const createWrap = (unsignedEvent: UnsignedEvent, receiver: string) => {
// eslint-disable-next-line no-constant-condition // eslint-disable-next-line no-constant-condition
while (true) { while (true) {
// Create an unsigned event with the necessary fields // Create an unsigned event with the necessary fields
// TODO: kinds.GiftWrap (wrong kind number in nostr-tools 10/11/2024 at v2.7.2)
const event: UnsignedEvent = { const event: UnsignedEvent = {
kind: 1059, // Event kind kind: 1059, // Event kind
content, // Encrypted content content, // Encrypted content
pubkey, // Public key of the creator pubkey, // Public key of the creator
created_at: unixNow(), // Current timestamp created_at: randomTimeUpTo2DaysInThePast(),
tags: [ tags: [
// Tags including receiver and nonce // Tags including receiver and nonce
['p', receiver], ['p', receiver],
@ -989,3 +1001,145 @@ export const getProfileUsername = (
truncate(profile?.display_name || profile?.name || hexToNpub(npub), { truncate(profile?.display_name || profile?.name || hexToNpub(npub), {
length: 16 length: 16
}) })
/**
* Modified {@link UnsignedEvent Unsigned Event} that includes an id
*
* Fields id and created_at are required.
* @see {@link UnsignedEvent}
* @see {@link https://github.com/nostr-protocol/nips/blob/master/17.md#direct-message-kind}
*/
type UnsignedEventWithId = UnsignedEvent & {
id?: string
}
export const sendPrivateDirectMessage = async (
message: string,
receiver: string,
subject?: string
) => {
// Instantiate the MetadataController to retrieve relay list metadata to look for preferred DM relays
const metadataController = MetadataController.getInstance()
const relaySet = await metadataController
.findRelayListMetadata(receiver)
.catch((err) => {
// Log an error if retrieving relay list metadata fails
console.log(
`An error occurred while finding relay list metadata for ${hexToNpub(receiver)}`,
err
)
return null
})
// Throw if metadata retrieval failed
if (!relaySet) {
throw new SendDMError(SendDMErrorType.METADATA_FETCH_FAILED, {
context: {
receiver
}
})
}
// Ensure relay list is not empty
if (relaySet.read.length === 0) {
throw new SendDMError(SendDMErrorType.RELAY_READ_EMPTY, {
context: {
receiver,
relaySet: JSON.stringify(relaySet)
}
})
}
// Get the direct message preferred relays list
// TODO: kinds.DirectMessageRelaysList (unavailabe in nostr-tools 10/10/2024 at v2.7.0)
// https://github.com/nostr-protocol/nips/blob/master/17.md#publishing
const eventFilter: Filter = {
kinds: [10050],
authors: [receiver]
}
const preferredRelaysListEvents =
await RelayController.getInstance().fetchEvents(eventFilter, relaySet.read)
const isRelayTag = (tag: string[]): boolean => tag[0] === 'relay'
const preferredRelaysList = preferredRelaysListEvents.reduce(
(previous: string[], current: Event) => {
const relaysList = current.tags
.filter((t) => isRelayTag(t) && !previous.includes(t[1]))
.map((t) => t[1])
return [...previous, ...relaysList]
},
[]
)
// Empty preferred relays list
const finalRelaysList: string[] =
preferredRelaysList?.length > 0 ? preferredRelaysList : [...relaySet.write]
// Generate "sender"
const senderSecret = generateSecretKey()
const senderPubkey = getPublicKey(senderSecret)
// Prepare tags for the message
const tags: string[][] = [['p', receiver]]
// Conversation title
if (subject) tags.push(['subject', subject])
// Create private DM event containing the message and relevant metadata
// TODO: kinds.PrivateDirectMessage (unavailabe in nostr-tools 10/10/2024 at v2.7.0)
const dm: UnsignedEventWithId = {
pubkey: senderPubkey,
created_at: unixNow(),
kind: 14,
tags,
content: message
}
// Calculate the hash based on the UnverifiedEvent
dm.id = getEventHash(dm)
// Encrypt the private dm using the sender secret and the receiver's public key
const encryptedDm = nip44Encrypt(dm, senderSecret, receiver)
if (!encryptedDm) {
throw new SendDMError(SendDMErrorType.ENCRYPTION_FAILED, {
context: {
receiver,
message,
kind: dm.kind
}
})
}
// Seal the message
// TODO: kinds.Seal (unavailabe in nostr-tools 10/10/2024 at v2.7.0)
const sealedMessage: UnsignedEvent = {
kind: 13, // seal
pubkey: senderPubkey,
content: encryptedDm,
created_at: randomTimeUpTo2DaysInThePast(),
tags: [] // no tags
}
// Finalize and sign the sealed event
const finalizedSeal = finalizeEvent(sealedMessage, senderSecret)
// Encrypt the seal and gift wrap
const finalizedGiftWrap = createWrap(finalizedSeal, receiver)
// Publish the finalized gift wrap event (the encrypted DM) to the relays
const publishedOnRelays = await relayController.publish(
finalizedGiftWrap,
finalRelaysList
)
// Handle cases where publishing to the relays failed
if (publishedOnRelays.length === 0) {
throw new SendDMError(SendDMErrorType.ENCRYPTION_FAILED, {
context: {
receiver,
count: publishedOnRelays.length
}
})
}
// Return true indicating that the DM was successfully sent
return true
}

View File

@ -97,20 +97,23 @@ const isOlderThanOneDay = (cachedAt: number) => {
} }
const isRelayTag = (tag: string[]): boolean => tag[0] === 'r' const isRelayTag = (tag: string[]): boolean => tag[0] === 'r'
const addRelay = (list: string[], relay: string) => {
// Only add if the list doesn't already include the relay
if (!list.includes(relay)) list.push(relay)
}
const toRelaySet = (obj: RelaySet, tag: string[]): RelaySet => { const toRelaySet = (obj: RelaySet, tag: string[]): RelaySet => {
if (tag.length >= 3) { if (tag.length >= 3) {
const marker = tag[2] const marker = tag[2]
if (marker === READ_MARKER) { if (marker === READ_MARKER) {
obj.read.push(tag[1]) addRelay(obj.read, tag[1])
} else if (marker === WRITE_MARKER) { } else if (marker === WRITE_MARKER) {
obj.write.push(tag[1]) addRelay(obj.write, tag[1])
} }
} }
if (tag.length === 2) { if (tag.length === 2) {
obj.read.push(tag[1]) addRelay(obj.read, tag[1])
obj.write.push(tag[1]) addRelay(obj.write, tag[1])
} }
return obj return obj