mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 07:52:24 +00:00
Implement worker-based NIP-04 DM sending
This commit is contained in:
1 parent
d87c5dbbcc
commit
56f2c1807c
5 files changed
+388
-76
No files matched your search
+180
-75
@@ -106,7 +106,9 @@ import {
|
||||
resolveSimplePoolConstructor,
|
||||
shimLegacySimplePoolMethods,
|
||||
} from "./toolkit.js";
|
||||
import { encryptNip04InWorker } from "./nip04WorkerClient.js";
|
||||
import { devLogger, userLogger } from "../utils/logger.js";
|
||||
import { updateConversationFromMessage, writeMessages } from "../storage/dmDb.js";
|
||||
import {
|
||||
DEFAULT_NIP07_ENCRYPTION_METHODS,
|
||||
DEFAULT_NIP07_PERMISSION_METHODS,
|
||||
@@ -4324,7 +4326,14 @@ export class NostrClient {
|
||||
return newPromise;
|
||||
}
|
||||
|
||||
async sendDirectMessage(targetNpub, message, actorPubkeyOverride = null) {
|
||||
async sendDirectMessage(targetNpub, message, actorPubkeyOverride = null, options = {}) {
|
||||
let resolvedOptions = options;
|
||||
let resolvedActorOverride = actorPubkeyOverride;
|
||||
if (actorPubkeyOverride && typeof actorPubkeyOverride === "object") {
|
||||
resolvedOptions = actorPubkeyOverride;
|
||||
resolvedActorOverride = null;
|
||||
}
|
||||
|
||||
const trimmedTarget = typeof targetNpub === "string" ? targetNpub.trim() : "";
|
||||
const trimmedMessage = typeof message === "string" ? message.trim() : "";
|
||||
|
||||
@@ -4341,12 +4350,12 @@ export class NostrClient {
|
||||
}
|
||||
|
||||
const activeSignerCandidate =
|
||||
typeof actorPubkeyOverride === "string" && actorPubkeyOverride.trim()
|
||||
? resolveActiveSigner(actorPubkeyOverride)
|
||||
typeof resolvedActorOverride === "string" && resolvedActorOverride.trim()
|
||||
? resolveActiveSigner(resolvedActorOverride)
|
||||
: resolveActiveSigner(this.pubkey);
|
||||
const baseActiveSigner = activeSignerCandidate || getActiveSigner();
|
||||
const signer =
|
||||
typeof actorPubkeyOverride === "string" && actorPubkeyOverride.trim()
|
||||
typeof resolvedActorOverride === "string" && resolvedActorOverride.trim()
|
||||
? activeSignerCandidate
|
||||
: baseActiveSigner
|
||||
? resolveActiveSigner(baseActiveSigner.pubkey || this.pubkey)
|
||||
@@ -4374,8 +4383,8 @@ export class NostrClient {
|
||||
}
|
||||
|
||||
let actorHex =
|
||||
typeof actorPubkeyOverride === "string" && actorPubkeyOverride.trim()
|
||||
? actorPubkeyOverride.trim()
|
||||
typeof resolvedActorOverride === "string" && resolvedActorOverride.trim()
|
||||
? resolvedActorOverride.trim()
|
||||
: "";
|
||||
|
||||
if (!actorHex && typeof this.pubkey === "string") {
|
||||
@@ -4386,67 +4395,87 @@ export class NostrClient {
|
||||
actorHex = signer.pubkey;
|
||||
}
|
||||
|
||||
if (!actorHex) {
|
||||
return { ok: false, error: "missing-actor-pubkey" };
|
||||
}
|
||||
|
||||
const targetHex = decodeNpubToHex(trimmedTarget);
|
||||
if (!targetHex) {
|
||||
return { ok: false, error: "invalid-target" };
|
||||
}
|
||||
|
||||
const encryptionCandidates = [];
|
||||
const signerCapabilities = resolveSignerCapabilities(signer);
|
||||
const allowNip04Fallback = signerCapabilities.nip04 === true;
|
||||
if (
|
||||
signerCapabilities.nip44 &&
|
||||
typeof signer.nip44Encrypt === "function"
|
||||
) {
|
||||
encryptionCandidates.push({
|
||||
scheme: "nip44",
|
||||
encrypt: signer.nip44Encrypt,
|
||||
});
|
||||
}
|
||||
if (
|
||||
signerCapabilities.nip04 &&
|
||||
typeof signer.nip04Encrypt === "function"
|
||||
) {
|
||||
encryptionCandidates.push({
|
||||
scheme: "nip04",
|
||||
encrypt: signer.nip04Encrypt,
|
||||
});
|
||||
const signingAdapter =
|
||||
resolvedOptions?.signingAdapter &&
|
||||
typeof resolvedOptions.signingAdapter === "object"
|
||||
? resolvedOptions.signingAdapter
|
||||
: signer && typeof signer.signEvent === "function"
|
||||
? {
|
||||
signEvent: (event) => signer.signEvent(event),
|
||||
getPubkey: async () => actorHex || signer.pubkey || "",
|
||||
getDisplayName: async () => "",
|
||||
}
|
||||
: null;
|
||||
|
||||
if (!signingAdapter || typeof signingAdapter.signEvent !== "function") {
|
||||
return { ok: false, error: "sign-event-unavailable" };
|
||||
}
|
||||
|
||||
if (typeof signingAdapter.getPubkey === "function") {
|
||||
try {
|
||||
const adapterPubkey = await signingAdapter.getPubkey();
|
||||
if (typeof adapterPubkey === "string" && adapterPubkey.trim()) {
|
||||
actorHex = adapterPubkey.trim();
|
||||
}
|
||||
} catch (error) {
|
||||
devLogger.warn("[nostr] Failed to resolve DM pubkey from adapter.", error);
|
||||
}
|
||||
}
|
||||
|
||||
if (!actorHex) {
|
||||
return { ok: false, error: "missing-actor-pubkey" };
|
||||
}
|
||||
|
||||
const signerCapabilities = resolveSignerCapabilities(signer);
|
||||
const encryptionErrors = [];
|
||||
let ciphertext = "";
|
||||
|
||||
for (const candidate of encryptionCandidates) {
|
||||
const normalizedActorHex = normalizeActorKey(actorHex);
|
||||
const sessionActor = this.sessionActor;
|
||||
const sessionPrivateKey =
|
||||
sessionActor &&
|
||||
typeof sessionActor.pubkey === "string" &&
|
||||
sessionActor.pubkey.toLowerCase() === normalizedActorHex &&
|
||||
typeof sessionActor.privateKey === "string"
|
||||
? sessionActor.privateKey
|
||||
: "";
|
||||
|
||||
if (sessionPrivateKey) {
|
||||
try {
|
||||
const encrypted = await candidate.encrypt(targetHex, trimmedMessage);
|
||||
ciphertext = await encryptNip04InWorker({
|
||||
privateKey: sessionPrivateKey,
|
||||
targetPubkey: targetHex,
|
||||
plaintext: trimmedMessage,
|
||||
});
|
||||
} catch (error) {
|
||||
encryptionErrors.push({ scheme: "nip04-worker", error });
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
!ciphertext &&
|
||||
signerCapabilities.nip04 &&
|
||||
typeof signer.nip04Encrypt === "function"
|
||||
) {
|
||||
try {
|
||||
const encrypted = await signer.nip04Encrypt(targetHex, trimmedMessage);
|
||||
if (typeof encrypted === "string" && encrypted) {
|
||||
ciphertext = encrypted;
|
||||
break;
|
||||
}
|
||||
} catch (error) {
|
||||
encryptionErrors.push({ scheme: candidate.scheme, error });
|
||||
encryptionErrors.push({ scheme: "nip04", error });
|
||||
}
|
||||
}
|
||||
|
||||
if (!ciphertext) {
|
||||
const normalizedActorHex = normalizeActorKey(actorHex);
|
||||
const sessionActor = this.sessionActor;
|
||||
const sessionMatchesActor =
|
||||
sessionActor &&
|
||||
typeof sessionActor.pubkey === "string" &&
|
||||
sessionActor.pubkey.toLowerCase() === normalizedActorHex &&
|
||||
typeof sessionActor.privateKey === "string" &&
|
||||
sessionActor.privateKey;
|
||||
|
||||
const canFallbackWithSession = sessionMatchesActor && allowNip04Fallback;
|
||||
|
||||
if (!encryptionCandidates.length && !canFallbackWithSession) {
|
||||
if (!sessionPrivateKey && !signerCapabilities.nip04) {
|
||||
const error = new Error(
|
||||
"Your signer does not support NIP-44 or NIP-04 encryption.",
|
||||
"Your signer does not support NIP-04 encryption.",
|
||||
);
|
||||
userLogger.warn("[nostr] Encryption unsupported for DM send.", error);
|
||||
return {
|
||||
@@ -4456,31 +4485,6 @@ export class NostrClient {
|
||||
};
|
||||
}
|
||||
|
||||
if (canFallbackWithSession) {
|
||||
try {
|
||||
const tools = (await ensureNostrTools()) || getCachedNostrTools();
|
||||
if (tools?.nip04 && typeof tools.nip04.encrypt === "function") {
|
||||
const encrypted = await tools.nip04.encrypt(
|
||||
sessionActor.privateKey,
|
||||
targetHex,
|
||||
trimmedMessage,
|
||||
);
|
||||
if (typeof encrypted === "string" && encrypted) {
|
||||
ciphertext = encrypted;
|
||||
}
|
||||
} else {
|
||||
encryptionErrors.push({
|
||||
scheme: "nip04",
|
||||
error: new Error("nostr-tools nip04 helpers unavailable"),
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
encryptionErrors.push({ scheme: "nip04", error });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!ciphertext) {
|
||||
const details =
|
||||
encryptionErrors.length === 1
|
||||
? encryptionErrors[0].error
|
||||
@@ -4501,13 +4505,103 @@ export class NostrClient {
|
||||
|
||||
let signedEvent;
|
||||
try {
|
||||
signedEvent = await queueSignEvent(signer, event);
|
||||
signedEvent = await signingAdapter.signEvent(event);
|
||||
} catch (error) {
|
||||
return { ok: false, error: "signature-failed", details: error };
|
||||
}
|
||||
|
||||
const relays =
|
||||
Array.isArray(this.relays) && this.relays.length ? this.relays : RELAY_URLS;
|
||||
if (!signedEvent || typeof signedEvent.id !== "string") {
|
||||
return { ok: false, error: "signature-failed" };
|
||||
}
|
||||
|
||||
const conversationId = `dm:${[actorHex, targetHex].sort().join(":")}`;
|
||||
const dmRecord = {
|
||||
id: signedEvent.id,
|
||||
conversation_id: conversationId,
|
||||
sender_pubkey: actorHex,
|
||||
receiver_pubkey: targetHex,
|
||||
created_at: event.created_at,
|
||||
kind: event.kind,
|
||||
content: trimmedMessage,
|
||||
tags: event.tags,
|
||||
status: "pending",
|
||||
seen: true,
|
||||
};
|
||||
|
||||
try {
|
||||
await writeMessages(dmRecord);
|
||||
await updateConversationFromMessage(dmRecord, {
|
||||
preview: trimmedMessage,
|
||||
unseenDelta: 0,
|
||||
});
|
||||
} catch (error) {
|
||||
devLogger.warn("[nostr] Failed to persist outgoing DM.", error);
|
||||
}
|
||||
|
||||
const relayListCandidates = sanitizeRelayList(
|
||||
Array.isArray(this.readRelays) && this.readRelays.length
|
||||
? this.readRelays
|
||||
: Array.isArray(this.relays) && this.relays.length
|
||||
? this.relays
|
||||
: RELAY_URLS,
|
||||
);
|
||||
|
||||
const parseRecipientRelays = (relayEvent) => {
|
||||
const tags = Array.isArray(relayEvent?.tags) ? relayEvent.tags : [];
|
||||
const seen = new Set();
|
||||
const candidates = [];
|
||||
|
||||
tags.forEach((tag) => {
|
||||
if (!Array.isArray(tag) || tag[0] !== "r") {
|
||||
return;
|
||||
}
|
||||
const url = typeof tag[1] === "string" ? tag[1].trim() : "";
|
||||
if (!url) {
|
||||
return;
|
||||
}
|
||||
const marker =
|
||||
typeof tag[2] === "string" ? tag[2].trim().toLowerCase() : "";
|
||||
if (marker === "write") {
|
||||
return;
|
||||
}
|
||||
if (!seen.has(url)) {
|
||||
seen.add(url);
|
||||
candidates.push(url);
|
||||
}
|
||||
});
|
||||
|
||||
return sanitizeRelayList(candidates);
|
||||
};
|
||||
|
||||
let recipientRelays = [];
|
||||
if (relayListCandidates.length) {
|
||||
try {
|
||||
await this.ensurePool();
|
||||
const events = await this.pool.list(relayListCandidates, [
|
||||
{ kinds: [10002], authors: [targetHex], limit: 1 },
|
||||
]);
|
||||
const sorted = Array.isArray(events)
|
||||
? events
|
||||
.filter((entry) => entry && entry.pubkey === targetHex)
|
||||
.sort((a, b) => (b?.created_at || 0) - (a?.created_at || 0))
|
||||
: [];
|
||||
if (sorted.length) {
|
||||
recipientRelays = parseRecipientRelays(sorted[0]);
|
||||
}
|
||||
} catch (error) {
|
||||
devLogger.warn("[nostr] Failed to load recipient relay list.", error);
|
||||
}
|
||||
}
|
||||
|
||||
const fallbackRelays = sanitizeRelayList(
|
||||
Array.isArray(this.writeRelays) && this.writeRelays.length
|
||||
? this.writeRelays
|
||||
: Array.isArray(this.relays) && this.relays.length
|
||||
? this.relays
|
||||
: RELAY_URLS,
|
||||
);
|
||||
|
||||
const relays = recipientRelays.length ? recipientRelays : fallbackRelays;
|
||||
|
||||
const publishResults = await Promise.all(
|
||||
relays.map((url) => publishEventToRelay(this.pool, url, signedEvent))
|
||||
@@ -4515,6 +4609,11 @@ export class NostrClient {
|
||||
|
||||
const success = publishResults.some((result) => result.success);
|
||||
if (!success) {
|
||||
try {
|
||||
await writeMessages({ ...dmRecord, status: "failed" });
|
||||
} catch (error) {
|
||||
devLogger.warn("[nostr] Failed to persist DM failure state.", error);
|
||||
}
|
||||
return {
|
||||
ok: false,
|
||||
error: "publish-failed",
|
||||
@@ -4522,6 +4621,12 @@ export class NostrClient {
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
await writeMessages({ ...dmRecord, status: "published" });
|
||||
} catch (error) {
|
||||
devLogger.warn("[nostr] Failed to persist DM publish state.", error);
|
||||
}
|
||||
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import { ensureNostrTools, getCachedNostrTools } from "./toolkit.js";
|
||||
import { devLogger } from "../utils/logger.js";
|
||||
|
||||
const workerScope = typeof self !== "undefined" ? self : null;
|
||||
|
||||
const postResponse = (payload) => {
|
||||
if (!workerScope || typeof workerScope.postMessage !== "function") {
|
||||
return;
|
||||
}
|
||||
workerScope.postMessage(payload);
|
||||
};
|
||||
|
||||
const ensureNip04 = async () => {
|
||||
const tools = (await ensureNostrTools()) || getCachedNostrTools();
|
||||
const nip04 = tools?.nip04;
|
||||
if (!nip04 || typeof nip04.encrypt !== "function") {
|
||||
throw new Error("nip04-unavailable");
|
||||
}
|
||||
return nip04;
|
||||
};
|
||||
|
||||
const normalizePayload = (payload) => {
|
||||
if (!payload || typeof payload !== "object") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const id =
|
||||
typeof payload.id === "string" || typeof payload.id === "number"
|
||||
? payload.id
|
||||
: null;
|
||||
const privateKey =
|
||||
typeof payload.privateKey === "string" ? payload.privateKey.trim() : "";
|
||||
const targetPubkey =
|
||||
typeof payload.targetPubkey === "string" ? payload.targetPubkey.trim() : "";
|
||||
const plaintext =
|
||||
typeof payload.plaintext === "string" ? payload.plaintext : "";
|
||||
|
||||
if (!id || !privateKey || !targetPubkey) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id,
|
||||
privateKey,
|
||||
targetPubkey,
|
||||
plaintext,
|
||||
};
|
||||
};
|
||||
|
||||
const handleMessage = async (event) => {
|
||||
const payload = normalizePayload(event?.data);
|
||||
if (!payload) {
|
||||
postResponse({
|
||||
id: event?.data?.id ?? null,
|
||||
ok: false,
|
||||
error: { message: "invalid-payload" },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const nip04 = await ensureNip04();
|
||||
const ciphertext = await nip04.encrypt(
|
||||
payload.privateKey,
|
||||
payload.targetPubkey,
|
||||
payload.plaintext,
|
||||
);
|
||||
|
||||
postResponse({
|
||||
id: payload.id,
|
||||
ok: true,
|
||||
ciphertext,
|
||||
});
|
||||
} catch (error) {
|
||||
devLogger.warn("[nip04Worker] Encryption failed", error);
|
||||
postResponse({
|
||||
id: payload.id,
|
||||
ok: false,
|
||||
error: {
|
||||
message: error?.message || "nip04-worker-failed",
|
||||
name: error?.name || "Error",
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
if (workerScope?.addEventListener) {
|
||||
workerScope.addEventListener("message", (event) => {
|
||||
void handleMessage(event);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import { devLogger } from "../utils/logger.js";
|
||||
|
||||
const DEFAULT_TIMEOUT_MS = 12000;
|
||||
|
||||
let workerInstance = null;
|
||||
let workerReady = false;
|
||||
let requestId = 0;
|
||||
const pending = new Map();
|
||||
|
||||
function attachWorkerListeners(worker) {
|
||||
if (!worker || workerReady) {
|
||||
return;
|
||||
}
|
||||
|
||||
worker.addEventListener("message", (event) => {
|
||||
const data = event?.data || {};
|
||||
const id = data.id;
|
||||
if (!pending.has(id)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const entry = pending.get(id);
|
||||
pending.delete(id);
|
||||
clearTimeout(entry.timeoutId);
|
||||
|
||||
if (data.ok && typeof data.ciphertext === "string") {
|
||||
entry.resolve(data.ciphertext);
|
||||
return;
|
||||
}
|
||||
|
||||
const message = data?.error?.message || "nip04-worker-error";
|
||||
const error = new Error(message);
|
||||
error.name = data?.error?.name || "Error";
|
||||
entry.reject(error);
|
||||
});
|
||||
|
||||
worker.addEventListener("error", (error) => {
|
||||
devLogger.warn("[nip04WorkerClient] Worker error", error);
|
||||
for (const entry of pending.values()) {
|
||||
clearTimeout(entry.timeoutId);
|
||||
entry.reject(error instanceof Error ? error : new Error("nip04-worker-error"));
|
||||
}
|
||||
pending.clear();
|
||||
});
|
||||
|
||||
workerReady = true;
|
||||
}
|
||||
|
||||
function ensureWorker() {
|
||||
if (workerInstance) {
|
||||
return workerInstance;
|
||||
}
|
||||
|
||||
if (typeof Worker === "undefined") {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
workerInstance = new Worker(new URL("./nip04Worker.js", import.meta.url), {
|
||||
type: "module",
|
||||
});
|
||||
attachWorkerListeners(workerInstance);
|
||||
} catch (error) {
|
||||
devLogger.warn("[nip04WorkerClient] Failed to create worker", error);
|
||||
workerInstance = null;
|
||||
}
|
||||
|
||||
return workerInstance;
|
||||
}
|
||||
|
||||
export function encryptNip04InWorker({
|
||||
privateKey,
|
||||
targetPubkey,
|
||||
plaintext,
|
||||
timeoutMs = DEFAULT_TIMEOUT_MS,
|
||||
} = {}) {
|
||||
const worker = ensureWorker();
|
||||
if (!worker) {
|
||||
return Promise.reject(new Error("nip04-worker-unavailable"));
|
||||
}
|
||||
|
||||
const trimmedPrivateKey =
|
||||
typeof privateKey === "string" ? privateKey.trim() : "";
|
||||
const trimmedTarget =
|
||||
typeof targetPubkey === "string" ? targetPubkey.trim() : "";
|
||||
const message = typeof plaintext === "string" ? plaintext : "";
|
||||
|
||||
if (!trimmedPrivateKey || !trimmedTarget) {
|
||||
return Promise.reject(new Error("nip04-worker-invalid-input"));
|
||||
}
|
||||
|
||||
const id = `${Date.now()}-${requestId++}`;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const timeoutId = setTimeout(() => {
|
||||
pending.delete(id);
|
||||
reject(new Error("nip04-worker-timeout"));
|
||||
}, timeoutMs);
|
||||
|
||||
pending.set(id, { resolve, reject, timeoutId });
|
||||
worker.postMessage({
|
||||
id,
|
||||
privateKey: trimmedPrivateKey,
|
||||
targetPubkey: trimmedTarget,
|
||||
plaintext: message,
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -135,6 +135,12 @@ function normalizeMessage(raw) {
|
||||
const receiverPubkey = normalizePubkey(
|
||||
raw.receiver_pubkey ?? raw.receiverPubkey ?? raw.to_pubkey,
|
||||
);
|
||||
const statusRaw = typeof raw.status === "string" ? raw.status.trim() : "";
|
||||
const status =
|
||||
statusRaw === "pending" || statusRaw === "published" || statusRaw === "failed"
|
||||
? statusRaw
|
||||
: "";
|
||||
const seen = raw.seen === true;
|
||||
|
||||
return {
|
||||
id,
|
||||
@@ -146,6 +152,8 @@ function normalizeMessage(raw) {
|
||||
content: typeof raw.content === "string" ? raw.content : "",
|
||||
tags: Array.isArray(raw.tags) ? raw.tags : [],
|
||||
relay: typeof raw.relay === "string" ? raw.relay : "",
|
||||
status,
|
||||
seen,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -2085,7 +2085,7 @@ export class ProfileModalController {
|
||||
case "sign-event-unavailable":
|
||||
return "Connect a Nostr signer to send messages.";
|
||||
case "encryption-unsupported":
|
||||
return "Your signer does not support NIP-44 or NIP-04 encryption.";
|
||||
return "Your signer does not support NIP-04 encryption.";
|
||||
case "extension-permission-denied":
|
||||
return "Please grant your Nostr extension permission to send messages.";
|
||||
case "missing-actor-pubkey":
|
||||
|
||||
Reference in new issue
Block a user