wip: Send dm on sigit sign/complete #230
@ -39,7 +39,9 @@ import {
|
||||
updateUsersAppData,
|
||||
uploadToFileStorage,
|
||||
DEFAULT_TOOLBOX,
|
||||
settleAllFullfilfedPromises
|
||||
settleAllFullfilfedPromises,
|
||||
sendPrivateDirectMessage,
|
||||
parseNostrEvent
|
||||
} from '../../utils'
|
||||
import { Container } from '../../components/Container'
|
||||
import fileListStyles from '../../components/FileList/style.module.scss'
|
||||
@ -62,6 +64,7 @@ import {
|
||||
} from '@fortawesome/free-solid-svg-icons'
|
||||
import { getSigitFile, SigitFile } from '../../utils/file.ts'
|
||||
import _ from 'lodash'
|
||||
import { SendDMError } from '../../types/errors/SendDMError.ts'
|
||||
|
||||
export const CreatePage = () => {
|
||||
const navigate = useNavigate()
|
||||
@ -713,6 +716,31 @@ export const CreatePage = () => {
|
||||
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 } })
|
||||
} else {
|
||||
const zip = new JSZip()
|
||||
|
@ -33,7 +33,9 @@ import {
|
||||
signEventForMetaFile,
|
||||
updateUsersAppData,
|
||||
findOtherUserMarks,
|
||||
timeout
|
||||
timeout,
|
||||
sendPrivateDirectMessage,
|
||||
parseNostrEvent
|
||||
} from '../../utils'
|
||||
import { Container } from '../../components/Container'
|
||||
import { DisplayMeta } from './internal/displayMeta'
|
||||
@ -53,6 +55,7 @@ import {
|
||||
SigitFile
|
||||
} from '../../utils/file.ts'
|
||||
import { ARRAY_BUFFER, DEFLATE } from '../../utils/const.ts'
|
||||
import { SendDMError } from '../../types/errors/SendDMError.ts'
|
||||
|
||||
enum SignedStatus {
|
||||
Fully_Signed,
|
||||
@ -720,6 +723,65 @@ export const SignPage = () => {
|
||||
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)
|
||||
}
|
||||
|
||||
|
24
src/types/errors/SendDMError.ts
Normal file
24
src/types/errors/SendDMError.ts
Normal 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
|
||||
}
|
||||
}
|
@ -6,6 +6,7 @@ import {
|
||||
EventTemplate,
|
||||
Filter,
|
||||
UnsignedEvent,
|
||||
VerifiedEvent,
|
||||
finalizeEvent,
|
||||
generateSecretKey,
|
||||
getEventHash,
|
||||
@ -21,7 +22,8 @@ import { NIP05_REGEX } from '../constants'
|
||||
import {
|
||||
MetadataController,
|
||||
NostrController,
|
||||
relayController
|
||||
relayController,
|
||||
RelayController
|
||||
} from '../controllers'
|
||||
import {
|
||||
updateProcessedGiftWraps,
|
||||
@ -35,6 +37,7 @@ import { parseJson, removeLeadingSlash } from './string'
|
||||
import { timeout } from './utils'
|
||||
import { getHash } from './hash'
|
||||
import { SIGIT_BLOSSOM } from './const.ts'
|
||||
import { SendDMError, SendDMErrorType } from '../types/errors/SendDMError.ts'
|
||||
|
||||
/**
|
||||
* Generates a `d` tag for userAppData
|
||||
@ -248,6 +251,12 @@ export const toUnixTimestamp = (date: number | Date) => {
|
||||
export const fromUnixTimestamp = (unix: number) => {
|
||||
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
|
||||
@ -297,19 +306,21 @@ export const countLeadingZeroes = (hex: string) => {
|
||||
|
||||
/**
|
||||
* 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 difficulty PoW difficulty level (default is 20)
|
||||
* @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
|
||||
const randomKey = generateSecretKey()
|
||||
const pubkey = getPublicKey(randomKey)
|
||||
|
||||
// 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
|
||||
let nonce = 0
|
||||
@ -320,11 +331,12 @@ export const createWrap = (unsignedEvent: UnsignedEvent, receiver: string) => {
|
||||
// eslint-disable-next-line no-constant-condition
|
||||
while (true) {
|
||||
// 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 = {
|
||||
kind: 1059, // Event kind
|
||||
content, // Encrypted content
|
||||
pubkey, // Public key of the creator
|
||||
created_at: unixNow(), // Current timestamp
|
||||
created_at: randomTimeUpTo2DaysInThePast(),
|
||||
tags: [
|
||||
// Tags including receiver and nonce
|
||||
['p', receiver],
|
||||
@ -989,3 +1001,145 @@ export const getProfileUsername = (
|
||||
truncate(profile?.display_name || profile?.name || hexToNpub(npub), {
|
||||
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
|
||||
}
|
||||
|
@ -97,20 +97,23 @@ const isOlderThanOneDay = (cachedAt: number) => {
|
||||
}
|
||||
|
||||
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 => {
|
||||
if (tag.length >= 3) {
|
||||
const marker = tag[2]
|
||||
|
||||
if (marker === READ_MARKER) {
|
||||
obj.read.push(tag[1])
|
||||
addRelay(obj.read, tag[1])
|
||||
} else if (marker === WRITE_MARKER) {
|
||||
obj.write.push(tag[1])
|
||||
addRelay(obj.write, tag[1])
|
||||
}
|
||||
}
|
||||
if (tag.length === 2) {
|
||||
obj.read.push(tag[1])
|
||||
obj.write.push(tag[1])
|
||||
addRelay(obj.read, tag[1])
|
||||
addRelay(obj.write, tag[1])
|
||||
}
|
||||
|
||||
return obj
|
||||
|
Loading…
Reference in New Issue
Block a user