Merge pull request #1381 from PR0M3TH3AN/codex/subscribe-to-kind-4-events-for-messages

Add DM decrypt worker, normalize & persist DM records, and track downloaded_until
This commit is contained in:
thePR0M3TH3AN authored and GitHub committed 2026-01-19 08:50:18 -05:00
commit 4b8f58f54c
5 files changed
+509

No files matched your search

+40
View File
@@ -107,6 +107,10 @@ import {
shimLegacySimplePoolMethods,
} from "./toolkit.js";
import { encryptNip04InWorker } from "./nip04WorkerClient.js";
import {
decryptDmInWorker,
isDmDecryptWorkerSupported,
} from "./dmDecryptWorkerClient.js";
import { devLogger, userLogger } from "../utils/logger.js";
import { updateConversationFromMessage, writeMessages } from "../storage/dmDb.js";
import {
@@ -4056,6 +4060,42 @@ export class NostrClient {
typeof sessionActor.privateKey === "string" &&
sessionActor.privateKey
) {
const canUseWorker = isDmDecryptWorkerSupported();
const sessionPrivateKey = sessionActor.privateKey;
if (canUseWorker) {
addCandidate(
"nip44",
(targetPubkey, ciphertext) =>
decryptDmInWorker({
scheme: "nip44",
privateKey: sessionPrivateKey,
targetPubkey,
ciphertext,
}),
{
priority: -6,
source: "worker",
supportsGiftWrap: true,
},
);
addCandidate(
"nip04",
(targetPubkey, ciphertext) =>
decryptDmInWorker({
scheme: "nip04",
privateKey: sessionPrivateKey,
targetPubkey,
ciphertext,
}),
{
priority: -4,
source: "worker",
},
);
}
if (
!this.sessionActorCipherClosures ||
this.sessionActorCipherClosuresPrivateKey !== sessionActor.privateKey
+210
View File
@@ -0,0 +1,210 @@
import { ensureNostrTools, getCachedNostrTools } from "./toolkit.js";
import { devLogger } from "../utils/logger.js";
const workerScope = typeof self !== "undefined" ? self : null;
const HEX64_REGEX = /^[0-9a-f]{64}$/i;
const postResponse = (payload) => {
if (!workerScope || typeof workerScope.postMessage !== "function") {
return;
}
workerScope.postMessage(payload);
};
const normalizeScheme = (scheme) => {
if (typeof scheme !== "string") {
return "nip04";
}
const normalized = scheme.trim().toLowerCase();
if (!normalized) {
return "nip04";
}
if (normalized === "nip44-v2") {
return "nip44_v2";
}
if (normalized === "nip-44") {
return "nip44";
}
if (normalized === "nip-04") {
return "nip04";
}
return normalized;
};
const normalizePayload = (payload) => {
if (!payload || typeof payload !== "object") {
return null;
}
const id =
typeof payload.id === "string" || typeof payload.id === "number"
? payload.id
: null;
const scheme = normalizeScheme(payload.scheme);
const privateKey =
typeof payload.privateKey === "string" ? payload.privateKey.trim() : "";
const targetPubkey =
typeof payload.targetPubkey === "string" ? payload.targetPubkey.trim() : "";
const ciphertext =
typeof payload.ciphertext === "string" ? payload.ciphertext : "";
if (!id || !privateKey || !targetPubkey || !ciphertext) {
return null;
}
return {
id,
scheme,
privateKey,
targetPubkey,
ciphertext,
};
};
const resolveHexToBytes = (tools) => {
if (typeof tools?.utils?.hexToBytes === "function") {
return (value) => tools.utils.hexToBytes(value);
}
return (value) => {
if (typeof value !== "string") {
throw new Error("Invalid hex input.");
}
const trimmed = value.trim();
if (!trimmed || trimmed.length % 2 !== 0) {
throw new Error("Invalid hex input.");
}
const bytes = new Uint8Array(trimmed.length / 2);
for (let index = 0; index < trimmed.length; index += 2) {
const byte = Number.parseInt(trimmed.slice(index, index + 2), 16);
if (Number.isNaN(byte)) {
throw new Error("Invalid hex input.");
}
bytes[index / 2] = byte;
}
return bytes;
};
};
const ensureNip04 = async () => {
const tools = (await ensureNostrTools()) || getCachedNostrTools();
const nip04 = tools?.nip04;
if (!nip04 || typeof nip04.decrypt !== "function") {
throw new Error("nip04-unavailable");
}
return nip04;
};
const ensureNip44 = async () => {
const tools = (await ensureNostrTools()) || getCachedNostrTools();
if (!tools) {
throw new Error("nip44-unavailable");
}
const nip44 = tools.nip44 || null;
let decrypt = null;
let getConversationKey = null;
if (nip44?.v2 && typeof nip44.v2 === "object") {
if (typeof nip44.v2.decrypt === "function") {
decrypt = nip44.v2.decrypt;
}
if (typeof nip44.v2?.utils?.getConversationKey === "function") {
getConversationKey = nip44.v2.utils.getConversationKey;
}
}
if ((!decrypt || !getConversationKey) && nip44 && typeof nip44 === "object") {
if (typeof nip44.decrypt === "function") {
decrypt = nip44.decrypt;
}
if (typeof nip44.getConversationKey === "function") {
getConversationKey = nip44.getConversationKey;
} else if (typeof nip44.utils?.getConversationKey === "function") {
getConversationKey = nip44.utils.getConversationKey;
}
}
if (!decrypt || !getConversationKey) {
throw new Error("nip44-unavailable");
}
return {
decrypt,
getConversationKey,
hexToBytes: resolveHexToBytes(tools),
};
};
const handleMessage = async (event) => {
const payload = normalizePayload(event?.data);
if (!payload) {
postResponse({
id: event?.data?.id ?? null,
ok: false,
error: { message: "invalid-payload" },
});
return;
}
if (!HEX64_REGEX.test(payload.privateKey)) {
postResponse({
id: payload.id,
ok: false,
error: { message: "invalid-private-key" },
});
return;
}
try {
if (payload.scheme === "nip44" || payload.scheme === "nip44_v2") {
const nip44 = await ensureNip44();
const privateKeyBytes = nip44.hexToBytes(payload.privateKey);
const conversationKey = nip44.getConversationKey(
privateKeyBytes,
payload.targetPubkey,
);
const plaintext = await nip44.decrypt(
payload.ciphertext,
conversationKey,
);
postResponse({
id: payload.id,
ok: true,
plaintext,
});
return;
}
const nip04 = await ensureNip04();
const plaintext = await nip04.decrypt(
payload.privateKey,
payload.targetPubkey,
payload.ciphertext,
);
postResponse({
id: payload.id,
ok: true,
plaintext,
});
} catch (error) {
devLogger.warn("[dmDecryptWorker] Decryption failed", error);
postResponse({
id: payload.id,
ok: false,
error: {
message: error?.message || "dm-worker-failed",
name: error?.name || "Error",
},
});
}
};
if (workerScope?.addEventListener) {
workerScope.addEventListener("message", (event) => {
void handleMessage(event);
});
}
+120
View File
@@ -0,0 +1,120 @@
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.plaintext === "string") {
entry.resolve(data.plaintext);
return;
}
const message = data?.error?.message || "dm-worker-error";
const error = new Error(message);
error.name = data?.error?.name || "Error";
entry.reject(error);
});
worker.addEventListener("error", (error) => {
devLogger.warn("[dmDecryptWorkerClient] Worker error", error);
for (const entry of pending.values()) {
clearTimeout(entry.timeoutId);
entry.reject(
error instanceof Error ? error : new Error("dm-worker-error"),
);
}
pending.clear();
});
workerReady = true;
}
function ensureWorker() {
if (workerInstance) {
return workerInstance;
}
if (typeof Worker === "undefined") {
return null;
}
try {
workerInstance = new Worker(
new URL("./dmDecryptWorker.js", import.meta.url),
{
type: "module",
},
);
attachWorkerListeners(workerInstance);
} catch (error) {
devLogger.warn("[dmDecryptWorkerClient] Failed to create worker", error);
workerInstance = null;
}
return workerInstance;
}
export function isDmDecryptWorkerSupported() {
return typeof Worker !== "undefined";
}
export function decryptDmInWorker({
scheme = "nip04",
privateKey,
targetPubkey,
ciphertext,
timeoutMs = DEFAULT_TIMEOUT_MS,
} = {}) {
const worker = ensureWorker();
if (!worker) {
return Promise.reject(new Error("dm-worker-unavailable"));
}
const trimmedPrivateKey =
typeof privateKey === "string" ? privateKey.trim() : "";
const trimmedTarget =
typeof targetPubkey === "string" ? targetPubkey.trim() : "";
const payloadCiphertext = typeof ciphertext === "string" ? ciphertext : "";
const payloadScheme = typeof scheme === "string" ? scheme : "";
if (!trimmedPrivateKey || !trimmedTarget || !payloadCiphertext) {
return Promise.reject(new Error("dm-worker-invalid-input"));
}
const id = `${Date.now()}-${requestId++}`;
return new Promise((resolve, reject) => {
const timeoutId = setTimeout(() => {
pending.delete(id);
reject(new Error("dm-worker-timeout"));
}, timeoutMs);
pending.set(id, { resolve, reject, timeoutId });
worker.postMessage({
id,
scheme: payloadScheme,
privateKey: trimmedPrivateKey,
targetPubkey: trimmedTarget,
ciphertext: payloadCiphertext,
});
});
}
+120
View File
@@ -9,6 +9,10 @@ import {
saveDirectMessageSnapshot,
clearDirectMessageSnapshot,
} from "../directMessagesStore.js";
import {
updateConversationFromMessage,
writeMessages,
} from "../storage/dmDb.js";
import {
getVideosMap as getStoredVideosMap,
setVideosMap as setStoredVideosMap,
@@ -286,6 +290,93 @@ function resolveDirectMessageRemotePubkey(message, actorPubkey = "") {
return "";
}
function buildConversationId(actorPubkey, remotePubkey) {
const normalizedActor = normalizeHexPubkey(actorPubkey);
const normalizedRemote = normalizeHexPubkey(remotePubkey);
if (!normalizedActor || !normalizedRemote) {
return "";
}
return `dm:${[normalizedActor, normalizedRemote].sort().join(":")}`;
}
function normalizeDirectMessageRecord(message, actorPubkey = "") {
if (!message || message.ok !== true) {
return null;
}
const actor = normalizeHexPubkey(
actorPubkey || message.actorPubkey || "",
);
const remote = resolveDirectMessageRemotePubkey(message, actor);
const conversationId = buildConversationId(actor, remote);
const eventId =
typeof message?.event?.id === "string" ? message.event.id : "";
if (!conversationId || !eventId) {
return null;
}
const senderPubkey = normalizeHexPubkey(
message?.sender?.pubkey ||
message?.message?.pubkey ||
message?.event?.pubkey ||
"",
);
const direction = message.direction;
let receiverPubkey = "";
if (direction === "incoming") {
receiverPubkey = actor;
} else if (direction === "outgoing") {
receiverPubkey = remote;
} else if (senderPubkey) {
receiverPubkey = senderPubkey === actor ? remote : actor;
}
const createdAt = sanitizeSnapshotTimestamp(
message?.message?.created_at ??
message?.event?.created_at ??
message?.timestamp,
);
const kind =
Number.isFinite(message?.message?.kind)
? message.message.kind
: Number.isFinite(message?.event?.kind)
? message.event.kind
: 0;
const tags = Array.isArray(message?.message?.tags)
? message.message.tags
: Array.isArray(message?.event?.tags)
? message.event.tags
: [];
const encryptionScheme =
typeof message?.scheme === "string"
? message.scheme.trim()
: typeof message?.decryptor?.scheme === "string"
? message.decryptor.scheme.trim()
: "";
return {
id: eventId,
conversation_id: conversationId,
sender_pubkey: senderPubkey || actor,
receiver_pubkey: receiverPubkey,
created_at: createdAt,
kind,
content: typeof message?.plaintext === "string" ? message.plaintext : "",
tags,
relay: typeof message?.event?.relay === "string" ? message.event.relay : "",
status: "published",
seen: direction !== "incoming",
encryption_scheme: encryptionScheme,
};
}
function buildSnapshotFromMessages(messages, actorPubkey = "") {
const normalizedActor = normalizeHexPubkey(actorPubkey);
const threadMap = new Map();
@@ -510,6 +601,31 @@ export class NostrService {
}
}
async persistDirectMessageRecord(message, { actorPubkey = null } = {}) {
const record = normalizeDirectMessageRecord(
message,
actorPubkey || message?.actorPubkey || this.dmActorPubkey,
);
if (!record) {
return null;
}
try {
await writeMessages(record);
await updateConversationFromMessage(record, {
preview: extractSnapshotPreview(message),
unseenDelta: message?.direction === "incoming" ? 1 : 0,
downloadedUntil: sanitizeSnapshotTimestamp(
message?.timestamp ?? message?.event?.created_at ?? record.created_at,
),
});
return record;
} catch (error) {
userLogger.warn("[nostrService] Failed to persist DM record", error);
return null;
}
}
applyDirectMessage(message, { reason = "update", event = null } = {}) {
if (!message || message.ok !== true) {
return;
@@ -575,6 +691,10 @@ export class NostrService {
);
});
}
if (!normalized.snapshot) {
void this.persistDirectMessageRecord(normalized, { actorPubkey: actor });
}
}
async hydrateDirectMessagesFromStore({
+19
View File
@@ -141,6 +141,14 @@ function normalizeMessage(raw) {
? statusRaw
: "";
const seen = raw.seen === true;
const encryptionScheme =
typeof raw.encryption_scheme === "string"
? raw.encryption_scheme.trim()
: typeof raw.encryptionScheme === "string"
? raw.encryptionScheme.trim()
: typeof raw.scheme === "string"
? raw.scheme.trim()
: "";
return {
id,
@@ -154,6 +162,7 @@ function normalizeMessage(raw) {
relay: typeof raw.relay === "string" ? raw.relay : "",
status,
seen,
encryption_scheme: encryptionScheme,
};
}
@@ -179,6 +188,9 @@ function normalizeConversation(raw) {
last_message_at: sanitizeTimestamp(
raw.last_message_at ?? raw.lastMessageAt,
),
downloaded_until: sanitizeTimestamp(
raw.downloaded_until ?? raw.downloadedUntil,
),
last_message_preview:
typeof raw.last_message_preview === "string"
? raw.last_message_preview
@@ -347,6 +359,9 @@ export async function updateConversationFromMessage(message, options = {}) {
Number.isFinite(Number(options.unseenDelta))
? Number(options.unseenDelta)
: 0;
const downloadedUntil = sanitizeTimestamp(
options.downloadedUntil ?? normalized.created_at,
);
try {
const db = await openDmDb();
@@ -368,6 +383,10 @@ export async function updateConversationFromMessage(message, options = {}) {
...existing,
created_at: existing.created_at || normalized.created_at,
last_message_at: normalized.created_at,
downloaded_until: Math.max(
existing.downloaded_until || 0,
downloadedUntil,
),
last_message_preview: preview,
unseen_count: Math.max(
0,