mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 16:02:26 +00:00
- Added fuzzing scripts for nostrEventSchemas, dmDecryptor, and magnetUtils. - Fixed crashes in nostrEventSchemas related to unsafe stringification of content. - Fixed crash in magnetUtils when options argument is null. - Added safeStringify helper in nostrEventSchemas.js. - Included fuzzing reports and reproducers. Co-authored-by: PR0M3TH3AN <[email protected]>
2416 lines
63 KiB
JavaScript
2416 lines
63 KiB
JavaScript
import { devLogger } from "./utils/logger.js";
|
|
import { decodeNpubToHex } from "./nostr/nip46Client.js";
|
|
import {
|
|
ADMIN_LIST_NAMESPACE,
|
|
ADMIN_COMMUNITY_BLACKLIST_SOURCES,
|
|
ADMIN_COMMUNITY_BLACKLIST_PREFIX,
|
|
isDevMode,
|
|
WATCH_HISTORY_KIND,
|
|
WATCH_HISTORY_LIST_IDENTIFIER,
|
|
WATCH_HISTORY_VERSION_TAG_VALUE,
|
|
} from "./config.js";
|
|
|
|
export const NOTE_TYPES = Object.freeze({
|
|
VIDEO_POST: "videoPost",
|
|
VIDEO_MIRROR: "videoMirror",
|
|
NIP71_VIDEO: "nip71Video",
|
|
NIP71_SHORT_VIDEO: "nip71ShortVideo",
|
|
REPOST: "repost",
|
|
SHARE: "share",
|
|
RELAY_LIST: "relayList",
|
|
VIEW_EVENT: "viewEvent",
|
|
VIDEO_REACTION: "videoReaction",
|
|
VIDEO_COMMENT: "videoComment",
|
|
DM_ATTACHMENT: "dmAttachment",
|
|
DM_READ_RECEIPT: "dmReadReceipt",
|
|
DM_TYPING: "dmTypingIndicator",
|
|
ZAP_REQUEST: "zapRequest",
|
|
ZAP_RECEIPT: "zapReceipt",
|
|
WATCH_HISTORY: "watchHistory",
|
|
SUBSCRIPTION_LIST: "subscriptionList",
|
|
USER_BLOCK_LIST: "userBlockList",
|
|
HASHTAG_PREFERENCES: "hashtagPreferences",
|
|
DM_RELAY_LIST: "dmRelayList",
|
|
ADMIN_MODERATION_LIST: "adminModerationList",
|
|
ADMIN_BLACKLIST: "adminBlacklist",
|
|
ADMIN_WHITELIST: "adminWhitelist",
|
|
PROFILE_METADATA: "profileMetadata",
|
|
MUTE_LIST: "muteList",
|
|
DELETION: "deletion",
|
|
LEGACY_DM: "legacyDm",
|
|
});
|
|
|
|
export const SUBSCRIPTION_LIST_IDENTIFIER = "subscriptions";
|
|
export const BLOCK_LIST_IDENTIFIER = "user-blocks";
|
|
export const ADMIN_LIST_IDENTIFIERS = Object.freeze({
|
|
moderation: "editors",
|
|
editors: "editors",
|
|
whitelist: "whitelist",
|
|
blacklist: "blacklist",
|
|
communityBlacklistSources: ADMIN_COMMUNITY_BLACKLIST_SOURCES,
|
|
});
|
|
|
|
export { ADMIN_COMMUNITY_BLACKLIST_SOURCES };
|
|
export { ADMIN_COMMUNITY_BLACKLIST_PREFIX };
|
|
|
|
const DEFAULT_APPEND_TAGS = [];
|
|
|
|
let cachedUtf8Encoder = null;
|
|
|
|
const ADDITIONAL_POINTER_TAGS = new Set(["p", "e"]);
|
|
const HEX_32_BYTE_REGEX = /^[0-9a-f]{64}$/i;
|
|
|
|
function safeStringify(value) {
|
|
if (typeof value === "string") {
|
|
return value;
|
|
}
|
|
if (value === null || value === undefined) {
|
|
return "";
|
|
}
|
|
try {
|
|
return String(value);
|
|
} catch (err) {
|
|
return "";
|
|
}
|
|
}
|
|
|
|
function normalizePointerIdentifier(value) {
|
|
if (typeof value !== "string") {
|
|
return "";
|
|
}
|
|
|
|
const trimmed = value.trim();
|
|
if (!trimmed) {
|
|
return "";
|
|
}
|
|
|
|
if (HEX_32_BYTE_REGEX.test(trimmed)) {
|
|
return trimmed.toLowerCase();
|
|
}
|
|
|
|
const decoded = decodeNpubToHex(trimmed);
|
|
if (decoded && HEX_32_BYTE_REGEX.test(decoded)) {
|
|
return decoded.toLowerCase();
|
|
}
|
|
|
|
return "";
|
|
}
|
|
|
|
function normalizeTagSlotValue(value) {
|
|
if (typeof value === "string") {
|
|
const trimmed = value.trim();
|
|
return trimmed;
|
|
}
|
|
|
|
if (typeof value === "number") {
|
|
if (!Number.isFinite(value)) {
|
|
return "";
|
|
}
|
|
return String(value);
|
|
}
|
|
|
|
if (value === null || value === undefined) {
|
|
return "";
|
|
}
|
|
|
|
return "";
|
|
}
|
|
|
|
export function sanitizeAdditionalTags(additionalTags) {
|
|
if (!Array.isArray(additionalTags) || !additionalTags.length) {
|
|
return [];
|
|
}
|
|
|
|
const sanitized = [];
|
|
|
|
additionalTags.forEach((tag) => {
|
|
if (!Array.isArray(tag) || tag.length < 2) {
|
|
return;
|
|
}
|
|
|
|
const rawName = tag[0];
|
|
if (typeof rawName !== "string") {
|
|
return;
|
|
}
|
|
|
|
const tagName = rawName.trim();
|
|
if (!tagName) {
|
|
return;
|
|
}
|
|
|
|
const lowerName = tagName.toLowerCase();
|
|
let primaryValue = "";
|
|
|
|
if (ADDITIONAL_POINTER_TAGS.has(lowerName)) {
|
|
primaryValue = normalizePointerIdentifier(tag[1]);
|
|
} else {
|
|
primaryValue = normalizeTagSlotValue(tag[1]);
|
|
}
|
|
|
|
if (!primaryValue) {
|
|
return;
|
|
}
|
|
|
|
const sanitizedTag = [tagName, primaryValue];
|
|
|
|
for (let index = 2; index < tag.length; index += 1) {
|
|
const extraValue = normalizeTagSlotValue(tag[index]);
|
|
if (extraValue) {
|
|
sanitizedTag.push(extraValue);
|
|
}
|
|
}
|
|
|
|
sanitized.push(sanitizedTag);
|
|
});
|
|
|
|
return sanitized;
|
|
}
|
|
|
|
function ensureValidUtf8Content(value) {
|
|
let normalized = "";
|
|
if (typeof value === "string") {
|
|
normalized = value;
|
|
} else if (value === undefined || value === null) {
|
|
normalized = "";
|
|
} else if (typeof value === "object") {
|
|
try {
|
|
const stringified = JSON.stringify(value);
|
|
normalized = typeof stringified === "string" ? stringified : "";
|
|
} catch (error) {
|
|
devLogger.warn(
|
|
"[nostrEventSchemas] Failed to serialize view event content:",
|
|
error
|
|
);
|
|
normalized = "";
|
|
}
|
|
} else {
|
|
normalized = String(value);
|
|
}
|
|
|
|
if (normalized) {
|
|
const builder = [];
|
|
let mutated = false;
|
|
for (let index = 0; index < normalized.length; index += 1) {
|
|
const code = normalized.charCodeAt(index);
|
|
if (code >= 0xd800 && code <= 0xdbff) {
|
|
const nextCode = normalized.charCodeAt(index + 1);
|
|
if (nextCode >= 0xdc00 && nextCode <= 0xdfff) {
|
|
builder.push(normalized[index], normalized[index + 1]);
|
|
index += 1;
|
|
} else {
|
|
mutated = true;
|
|
}
|
|
} else if (code >= 0xdc00 && code <= 0xdfff) {
|
|
mutated = true;
|
|
} else {
|
|
builder.push(normalized[index]);
|
|
}
|
|
}
|
|
if (mutated) {
|
|
devLogger.warn(
|
|
"[nostrEventSchemas] Dropping unmatched surrogate characters from event content"
|
|
);
|
|
normalized = builder.join("");
|
|
}
|
|
}
|
|
|
|
if (!cachedUtf8Encoder && typeof TextEncoder !== "undefined") {
|
|
cachedUtf8Encoder = new TextEncoder();
|
|
}
|
|
|
|
if (cachedUtf8Encoder) {
|
|
try {
|
|
cachedUtf8Encoder.encode(normalized);
|
|
} catch (error) {
|
|
devLogger.warn(
|
|
"[nostrEventSchemas] Dropping invalid UTF-8 characters from event content",
|
|
error
|
|
);
|
|
const sanitized = Array.from(normalized)
|
|
.filter((char) => {
|
|
const code = char.charCodeAt(0);
|
|
return code < 0xd800 || code > 0xdfff;
|
|
})
|
|
.join("");
|
|
try {
|
|
cachedUtf8Encoder.encode(sanitized);
|
|
normalized = sanitized;
|
|
} catch (encodeError) {
|
|
devLogger.warn(
|
|
"[nostrEventSchemas] Failed to normalize view event content; defaulting to empty string",
|
|
encodeError
|
|
);
|
|
normalized = "";
|
|
}
|
|
}
|
|
}
|
|
|
|
return normalized;
|
|
}
|
|
|
|
const BASE_SCHEMAS = {
|
|
[NOTE_TYPES.VIDEO_POST]: {
|
|
type: NOTE_TYPES.VIDEO_POST,
|
|
label: "Video post",
|
|
kind: 30078,
|
|
topicTag: { name: "t", value: "video" },
|
|
identifierTag: { name: "d" },
|
|
appendTags: DEFAULT_APPEND_TAGS,
|
|
content: {
|
|
format: "json",
|
|
schemaVersion: 3,
|
|
fields: [
|
|
{ key: "version", type: "number", required: true },
|
|
{ key: "title", type: "string", required: true },
|
|
{ key: "url", type: "string", required: false },
|
|
{ key: "magnet", type: "string", required: false },
|
|
{ key: "thumbnail", type: "string", required: false },
|
|
{ key: "description", type: "string", required: false },
|
|
{ key: "mode", type: "string", required: false },
|
|
{ key: "videoRootId", type: "string", required: true },
|
|
{ key: "deleted", type: "boolean", required: false },
|
|
{ key: "isPrivate", type: "boolean", required: false },
|
|
{ key: "isNsfw", type: "boolean", required: false },
|
|
{ key: "isForKids", type: "boolean", required: false },
|
|
{ key: "enableComments", type: "boolean", required: false },
|
|
{ key: "ws", type: "string", required: false },
|
|
{ key: "xs", type: "string", required: false },
|
|
],
|
|
},
|
|
},
|
|
[NOTE_TYPES.VIDEO_MIRROR]: {
|
|
type: NOTE_TYPES.VIDEO_MIRROR,
|
|
label: "NIP-94 mirror",
|
|
kind: 1063,
|
|
appendTags: DEFAULT_APPEND_TAGS,
|
|
content: {
|
|
format: "text",
|
|
description: "Optional alt text carried alongside hosted URL metadata.",
|
|
},
|
|
},
|
|
[NOTE_TYPES.REPOST]: {
|
|
type: NOTE_TYPES.REPOST,
|
|
label: "NIP-18 repost",
|
|
kind: 6,
|
|
appendTags: DEFAULT_APPEND_TAGS,
|
|
content: {
|
|
format: "json",
|
|
description: "Content field contains the JSON-serialized event being reposted.",
|
|
},
|
|
},
|
|
[NOTE_TYPES.SHARE]: {
|
|
type: NOTE_TYPES.SHARE,
|
|
label: "Share note",
|
|
kind: 1,
|
|
relayTagName: "r",
|
|
readMarker: "read",
|
|
writeMarker: "write",
|
|
appendTags: DEFAULT_APPEND_TAGS,
|
|
content: {
|
|
format: "text",
|
|
description: "Plaintext share note referencing a video.",
|
|
},
|
|
},
|
|
[NOTE_TYPES.NIP71_VIDEO]: {
|
|
type: NOTE_TYPES.NIP71_VIDEO,
|
|
label: "NIP-71 video (normal)",
|
|
kind: 21,
|
|
featureFlag: "FEATURE_PUBLISH_NIP71",
|
|
appendTags: DEFAULT_APPEND_TAGS,
|
|
content: {
|
|
format: "text",
|
|
description:
|
|
"Summary or description for the video body; tags carry structured metadata.",
|
|
},
|
|
},
|
|
[NOTE_TYPES.NIP71_SHORT_VIDEO]: {
|
|
type: NOTE_TYPES.NIP71_SHORT_VIDEO,
|
|
label: "NIP-71 video (short)",
|
|
kind: 22,
|
|
featureFlag: "FEATURE_PUBLISH_NIP71",
|
|
appendTags: DEFAULT_APPEND_TAGS,
|
|
content: {
|
|
format: "text",
|
|
description:
|
|
"Summary or description for the short-form video; structured fields live in tags.",
|
|
},
|
|
},
|
|
[NOTE_TYPES.RELAY_LIST]: {
|
|
type: NOTE_TYPES.RELAY_LIST,
|
|
label: "Relay list metadata",
|
|
kind: 10002,
|
|
relayTagName: "r",
|
|
readMarker: "read",
|
|
writeMarker: "write",
|
|
appendTags: DEFAULT_APPEND_TAGS,
|
|
content: { format: "empty", description: "Content field unused." },
|
|
},
|
|
[NOTE_TYPES.DM_RELAY_LIST]: {
|
|
type: NOTE_TYPES.DM_RELAY_LIST,
|
|
label: "DM relay hints",
|
|
kind: 10050,
|
|
relayTagName: "relay",
|
|
appendTags: DEFAULT_APPEND_TAGS,
|
|
content: { format: "empty", description: "Content field unused." },
|
|
},
|
|
[NOTE_TYPES.VIEW_EVENT]: {
|
|
type: NOTE_TYPES.VIEW_EVENT,
|
|
label: "View counter",
|
|
kind: WATCH_HISTORY_KIND,
|
|
topicTag: { name: "t", value: "view" },
|
|
identifierTag: { name: "d" },
|
|
sessionTag: { name: "session", value: "true" },
|
|
appendTags: DEFAULT_APPEND_TAGS,
|
|
content: {
|
|
format: "text",
|
|
description: "Optional plaintext content used for diagnostics.",
|
|
},
|
|
},
|
|
[NOTE_TYPES.VIDEO_REACTION]: {
|
|
type: NOTE_TYPES.VIDEO_REACTION,
|
|
label: "Reaction event",
|
|
kind: 7,
|
|
appendTags: DEFAULT_APPEND_TAGS,
|
|
content: {
|
|
format: "text",
|
|
description:
|
|
"Reaction payload for the referenced event (e.g., '+', '-', or emoji).",
|
|
},
|
|
},
|
|
[NOTE_TYPES.VIDEO_COMMENT]: {
|
|
type: NOTE_TYPES.VIDEO_COMMENT,
|
|
label: "Video comment",
|
|
kind: 1111,
|
|
videoEventTagName: "E",
|
|
videoDefinitionTagName: "A",
|
|
parentCommentTagName: "E",
|
|
parentAuthorTagName: "P",
|
|
rootEventPointerTagName: "E",
|
|
rootDefinitionPointerTagName: "A",
|
|
rootIdentifierPointerTagName: "I",
|
|
rootKindTagName: "K",
|
|
rootAuthorTagName: "P",
|
|
parentKindTagName: "K",
|
|
appendTags: DEFAULT_APPEND_TAGS,
|
|
content: {
|
|
format: "text",
|
|
description: "Plain text comment body sanitized for UTF-8 compatibility.",
|
|
},
|
|
},
|
|
[NOTE_TYPES.DM_ATTACHMENT]: {
|
|
type: NOTE_TYPES.DM_ATTACHMENT,
|
|
label: "DM attachment (NIP-17 file rumor)",
|
|
kind: 15,
|
|
participantTagName: "p",
|
|
hashTagName: "x",
|
|
urlTagName: "url",
|
|
nameTagName: "name",
|
|
typeTagName: "type",
|
|
sizeTagName: "size",
|
|
keyTagName: "k",
|
|
appendTags: DEFAULT_APPEND_TAGS,
|
|
content: {
|
|
format: "empty",
|
|
description: "Attachment metadata is carried in tags; content is empty.",
|
|
},
|
|
},
|
|
[NOTE_TYPES.DM_READ_RECEIPT]: {
|
|
type: NOTE_TYPES.DM_READ_RECEIPT,
|
|
label: "DM read receipt",
|
|
kind: 20001,
|
|
recipientTagName: "p",
|
|
eventTagName: "e",
|
|
kindTagName: "k",
|
|
appendTags: DEFAULT_APPEND_TAGS,
|
|
content: {
|
|
format: "empty",
|
|
description: "Ephemeral read receipt pointer for a DM event.",
|
|
},
|
|
},
|
|
[NOTE_TYPES.DM_TYPING]: {
|
|
type: NOTE_TYPES.DM_TYPING,
|
|
label: "DM typing indicator",
|
|
kind: 20002,
|
|
recipientTagName: "p",
|
|
eventTagName: "e",
|
|
statusTagName: "t",
|
|
statusTagValue: "typing",
|
|
expirationTagName: "expiration",
|
|
appendTags: DEFAULT_APPEND_TAGS,
|
|
content: {
|
|
format: "empty",
|
|
description: "Ephemeral typing indicator with an expiration timestamp.",
|
|
},
|
|
},
|
|
[NOTE_TYPES.ZAP_REQUEST]: {
|
|
type: NOTE_TYPES.ZAP_REQUEST,
|
|
label: "Zap request",
|
|
kind: 9734,
|
|
appendTags: DEFAULT_APPEND_TAGS,
|
|
content: {
|
|
format: "text",
|
|
description:
|
|
"Optional zap message included in the zap request sent to LNURL pay callbacks.",
|
|
},
|
|
},
|
|
[NOTE_TYPES.ZAP_RECEIPT]: {
|
|
type: NOTE_TYPES.ZAP_RECEIPT,
|
|
label: "Zap receipt",
|
|
kind: 9735,
|
|
appendTags: DEFAULT_APPEND_TAGS,
|
|
content: {
|
|
format: "text",
|
|
description:
|
|
"Zap receipts are published by lightning wallets to confirm zap requests.",
|
|
},
|
|
},
|
|
[NOTE_TYPES.WATCH_HISTORY]: {
|
|
type: NOTE_TYPES.WATCH_HISTORY,
|
|
label: "Watch history month",
|
|
kind: WATCH_HISTORY_KIND,
|
|
identifierTag: {
|
|
name: "d",
|
|
},
|
|
monthTagName: "month",
|
|
appendTags: [["v", WATCH_HISTORY_VERSION_TAG_VALUE]],
|
|
content: {
|
|
format: "json",
|
|
description:
|
|
"JSON payload containing a month's watched event identifiers with optional watchedAt metadata.",
|
|
},
|
|
},
|
|
[NOTE_TYPES.SUBSCRIPTION_LIST]: {
|
|
type: NOTE_TYPES.SUBSCRIPTION_LIST,
|
|
label: "Subscription list",
|
|
kind: 30000,
|
|
identifierTag: {
|
|
name: "d",
|
|
value: SUBSCRIPTION_LIST_IDENTIFIER,
|
|
},
|
|
appendTags: DEFAULT_APPEND_TAGS,
|
|
content: {
|
|
format: "encrypted-tag-list",
|
|
description:
|
|
"NIP-04/NIP-44 encrypted JSON array of NIP-51 tag tuples (e.g., [['p', <hex>], …]).",
|
|
},
|
|
},
|
|
[NOTE_TYPES.USER_BLOCK_LIST]: {
|
|
type: NOTE_TYPES.USER_BLOCK_LIST,
|
|
label: "User block list",
|
|
kind: 10000,
|
|
identifierTag: {
|
|
name: "d",
|
|
value: BLOCK_LIST_IDENTIFIER,
|
|
},
|
|
appendTags: DEFAULT_APPEND_TAGS,
|
|
content: {
|
|
format: "encrypted-tag-list",
|
|
description:
|
|
"NIP-04/NIP-44 encrypted JSON array of mute tags per NIP-51 (e.g., [['p', <hex>], …]).",
|
|
},
|
|
},
|
|
[NOTE_TYPES.HASHTAG_PREFERENCES]: {
|
|
type: NOTE_TYPES.HASHTAG_PREFERENCES,
|
|
label: "Hashtag preferences",
|
|
kind: 30015,
|
|
identifierTag: {
|
|
name: "d",
|
|
value: "bitvid:tag-preferences",
|
|
},
|
|
appendTags: [["encrypted", "nip44_v2"]],
|
|
content: {
|
|
format: "nip44-json",
|
|
description:
|
|
"NIP-44 encrypted JSON: { version, interests: string[], disinterests: string[] }.",
|
|
},
|
|
},
|
|
[NOTE_TYPES.ADMIN_MODERATION_LIST]: {
|
|
type: NOTE_TYPES.ADMIN_MODERATION_LIST,
|
|
label: "Admin moderation list",
|
|
kind: 30000,
|
|
identifierTag: {
|
|
name: "d",
|
|
value: `${ADMIN_LIST_NAMESPACE}:${ADMIN_LIST_IDENTIFIERS.moderation}`,
|
|
},
|
|
participantTagName: "p",
|
|
appendTags: DEFAULT_APPEND_TAGS,
|
|
content: { format: "empty", description: "Content field unused." },
|
|
},
|
|
[NOTE_TYPES.ADMIN_BLACKLIST]: {
|
|
type: NOTE_TYPES.ADMIN_BLACKLIST,
|
|
label: "Admin blacklist",
|
|
kind: 30000,
|
|
identifierTag: {
|
|
name: "d",
|
|
value: `${ADMIN_LIST_NAMESPACE}:${ADMIN_LIST_IDENTIFIERS.blacklist}`,
|
|
},
|
|
participantTagName: "p",
|
|
appendTags: DEFAULT_APPEND_TAGS,
|
|
content: { format: "empty", description: "Content field unused." },
|
|
},
|
|
[NOTE_TYPES.ADMIN_WHITELIST]: {
|
|
type: NOTE_TYPES.ADMIN_WHITELIST,
|
|
label: "Admin whitelist",
|
|
kind: 30000,
|
|
identifierTag: {
|
|
name: "d",
|
|
value: `${ADMIN_LIST_NAMESPACE}:${ADMIN_LIST_IDENTIFIERS.whitelist}`,
|
|
},
|
|
participantTagName: "p",
|
|
appendTags: DEFAULT_APPEND_TAGS,
|
|
content: { format: "empty", description: "Content field unused." },
|
|
},
|
|
[NOTE_TYPES.PROFILE_METADATA]: {
|
|
type: NOTE_TYPES.PROFILE_METADATA,
|
|
label: "Profile metadata",
|
|
kind: 0,
|
|
appendTags: DEFAULT_APPEND_TAGS,
|
|
content: {
|
|
format: "json",
|
|
description: "Standard NIP-01 profile metadata (name, about, picture, etc.).",
|
|
},
|
|
},
|
|
[NOTE_TYPES.MUTE_LIST]: {
|
|
type: NOTE_TYPES.MUTE_LIST,
|
|
label: "Mute list",
|
|
kind: 10000,
|
|
participantTagName: "p",
|
|
appendTags: DEFAULT_APPEND_TAGS,
|
|
content: {
|
|
format: "text",
|
|
description: "Optional content (often encrypted) with public p tags.",
|
|
},
|
|
},
|
|
[NOTE_TYPES.DELETION]: {
|
|
type: NOTE_TYPES.DELETION,
|
|
label: "Deletion",
|
|
kind: 5,
|
|
appendTags: DEFAULT_APPEND_TAGS,
|
|
content: {
|
|
format: "text",
|
|
description: "Reason for deletion.",
|
|
},
|
|
},
|
|
[NOTE_TYPES.LEGACY_DM]: {
|
|
type: NOTE_TYPES.LEGACY_DM,
|
|
label: "Legacy Direct Message",
|
|
kind: 4,
|
|
appendTags: DEFAULT_APPEND_TAGS,
|
|
recipientTagName: "p",
|
|
content: {
|
|
format: "text",
|
|
description: "NIP-04 encrypted ciphertext.",
|
|
},
|
|
},
|
|
};
|
|
|
|
let schemaOverrides = {};
|
|
|
|
function clone(value) {
|
|
if (Array.isArray(value)) {
|
|
return value.map(clone);
|
|
}
|
|
if (value && typeof value === "object") {
|
|
const result = {};
|
|
for (const [key, nested] of Object.entries(value)) {
|
|
result[key] = clone(nested);
|
|
}
|
|
return result;
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function mergeDeep(base, override) {
|
|
const result = clone(base);
|
|
if (!override || typeof override !== "object") {
|
|
return result;
|
|
}
|
|
for (const [key, value] of Object.entries(override)) {
|
|
if (value && typeof value === "object" && !Array.isArray(value)) {
|
|
result[key] = mergeDeep(base?.[key] ?? {}, value);
|
|
} else {
|
|
result[key] = clone(value);
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
|
|
function resolveOverride(type) {
|
|
const local = schemaOverrides?.[type];
|
|
if (local) {
|
|
return local;
|
|
}
|
|
|
|
if (typeof window !== "undefined") {
|
|
const runtimeOverrides = window.bitvidNostrEventOverrides;
|
|
if (runtimeOverrides && runtimeOverrides[type]) {
|
|
return runtimeOverrides[type];
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
export function setNostrEventSchemaOverrides(overrides = {}) {
|
|
if (overrides && typeof overrides === "object") {
|
|
schemaOverrides = overrides;
|
|
} else {
|
|
schemaOverrides = {};
|
|
}
|
|
|
|
if (typeof window !== "undefined") {
|
|
window.bitvidNostrEventOverrides = schemaOverrides;
|
|
}
|
|
}
|
|
|
|
export function getNostrEventSchema(type) {
|
|
const base = BASE_SCHEMAS[type];
|
|
if (!base) {
|
|
return null;
|
|
}
|
|
const override = resolveOverride(type);
|
|
return mergeDeep(base, override);
|
|
}
|
|
|
|
export function getAllNostrEventSchemas() {
|
|
const entries = {};
|
|
for (const type of Object.keys(BASE_SCHEMAS)) {
|
|
entries[type] = getNostrEventSchema(type);
|
|
}
|
|
return entries;
|
|
}
|
|
|
|
function appendSchemaTags(tags, schema) {
|
|
if (!Array.isArray(schema?.appendTags)) {
|
|
return tags;
|
|
}
|
|
schema.appendTags.forEach((tag) => {
|
|
if (Array.isArray(tag) && tag.length >= 2) {
|
|
tags.push(tag.map((value) => (typeof value === "string" ? value : String(value))));
|
|
}
|
|
});
|
|
return tags;
|
|
}
|
|
|
|
function collectPointerTags(schema, {
|
|
pointerValue,
|
|
pointerTag,
|
|
pointerTags = [],
|
|
} = {}) {
|
|
const normalized = [];
|
|
|
|
const pointerTagName = schema?.pointerTagName;
|
|
if (pointerValue && pointerTagName) {
|
|
const normalizedValue =
|
|
typeof pointerValue === "string" ? pointerValue : String(pointerValue);
|
|
if (normalizedValue) {
|
|
normalized.push([pointerTagName, normalizedValue]);
|
|
}
|
|
}
|
|
|
|
if (Array.isArray(pointerTag) && pointerTag.length >= 2) {
|
|
normalized.push(
|
|
pointerTag.map((value) => (typeof value === "string" ? value : String(value)))
|
|
);
|
|
}
|
|
|
|
if (Array.isArray(pointerTags)) {
|
|
pointerTags.forEach((tag) => {
|
|
if (Array.isArray(tag) && tag.length >= 2) {
|
|
normalized.push(
|
|
tag.map((value) => (typeof value === "string" ? value : String(value)))
|
|
);
|
|
}
|
|
});
|
|
}
|
|
|
|
return normalized;
|
|
}
|
|
|
|
function mergePointerTags(pointerTags = []) {
|
|
if (!Array.isArray(pointerTags) || pointerTags.length === 0) {
|
|
return [];
|
|
}
|
|
|
|
const merged = [];
|
|
const pointerIndexByKey = new Map();
|
|
|
|
const toTrimmedString = (value) => {
|
|
if (typeof value === "string") {
|
|
return value.trim();
|
|
}
|
|
if (value === undefined || value === null) {
|
|
return "";
|
|
}
|
|
return String(value).trim();
|
|
};
|
|
|
|
pointerTags.forEach((tag) => {
|
|
if (!Array.isArray(tag) || tag.length < 2) {
|
|
return;
|
|
}
|
|
|
|
const normalizedType = toTrimmedString(tag[0]);
|
|
if (!normalizedType) {
|
|
return;
|
|
}
|
|
|
|
const canonicalType =
|
|
normalizedType === "a" ? "a" : normalizedType === "e" ? "e" : normalizedType;
|
|
|
|
const normalizedValue = toTrimmedString(tag[1]);
|
|
if (!normalizedValue) {
|
|
return;
|
|
}
|
|
|
|
const sanitizedTag = [canonicalType, normalizedValue];
|
|
for (let index = 2; index < tag.length; index += 1) {
|
|
const extra = toTrimmedString(tag[index]);
|
|
if (extra) {
|
|
sanitizedTag.push(extra);
|
|
}
|
|
}
|
|
|
|
if (canonicalType !== "a" && canonicalType !== "e") {
|
|
merged.push(sanitizedTag);
|
|
return;
|
|
}
|
|
|
|
const dedupeKey = `${canonicalType}:${normalizedValue.toLowerCase()}`;
|
|
const relayHint =
|
|
sanitizedTag.length > 2 && typeof sanitizedTag[2] === "string"
|
|
? sanitizedTag[2].trim()
|
|
: "";
|
|
|
|
if (!pointerIndexByKey.has(dedupeKey)) {
|
|
if (relayHint && sanitizedTag.length > 2) {
|
|
sanitizedTag[2] = relayHint;
|
|
}
|
|
const index = merged.length;
|
|
merged.push(sanitizedTag);
|
|
pointerIndexByKey.set(dedupeKey, { index, relay: relayHint });
|
|
return;
|
|
}
|
|
|
|
const existingEntry = pointerIndexByKey.get(dedupeKey);
|
|
const existingTag = merged[existingEntry.index];
|
|
const existingRelay =
|
|
existingTag.length > 2 && typeof existingTag[2] === "string"
|
|
? existingTag[2].trim()
|
|
: "";
|
|
const hasExistingRelay = Boolean(existingRelay);
|
|
const hasNewRelay = Boolean(relayHint);
|
|
|
|
if (
|
|
(!hasExistingRelay && hasNewRelay) ||
|
|
(hasExistingRelay && hasNewRelay && existingRelay !== relayHint)
|
|
) {
|
|
const replacement = sanitizedTag.slice();
|
|
if (relayHint && replacement.length > 2) {
|
|
replacement[2] = relayHint;
|
|
}
|
|
merged[existingEntry.index] = replacement;
|
|
pointerIndexByKey.set(dedupeKey, {
|
|
index: existingEntry.index,
|
|
relay: relayHint,
|
|
});
|
|
}
|
|
});
|
|
|
|
return merged;
|
|
}
|
|
|
|
export function buildVideoPostEvent(params) {
|
|
const {
|
|
pubkey,
|
|
created_at,
|
|
dTagValue,
|
|
content,
|
|
additionalTags = [],
|
|
} = params || {};
|
|
const schema = getNostrEventSchema(NOTE_TYPES.VIDEO_POST);
|
|
const tags = [];
|
|
if (schema?.topicTag?.name && schema?.topicTag?.value) {
|
|
tags.push([schema.topicTag.name, schema.topicTag.value]);
|
|
}
|
|
if (schema?.identifierTag?.name && dTagValue) {
|
|
tags.push([schema.identifierTag.name, dTagValue]);
|
|
}
|
|
appendSchemaTags(tags, schema);
|
|
const sanitizedAdditionalTags = sanitizeAdditionalTags(additionalTags);
|
|
if (sanitizedAdditionalTags.length) {
|
|
tags.push(...sanitizedAdditionalTags.map((tag) => tag.slice()));
|
|
}
|
|
|
|
let serializedContent = "";
|
|
if (typeof content === "string") {
|
|
serializedContent = content;
|
|
} else {
|
|
try {
|
|
serializedContent = JSON.stringify(content ?? {});
|
|
} catch (error) {
|
|
serializedContent = "{}";
|
|
}
|
|
}
|
|
|
|
const event = {
|
|
kind: schema?.kind ?? 30078,
|
|
pubkey,
|
|
created_at,
|
|
tags,
|
|
content: serializedContent,
|
|
};
|
|
|
|
if (isDevMode) {
|
|
validateEventAgainstSchema(NOTE_TYPES.VIDEO_POST, event);
|
|
}
|
|
|
|
return event;
|
|
}
|
|
|
|
export function buildVideoMirrorEvent(params) {
|
|
const {
|
|
pubkey,
|
|
created_at,
|
|
tags = [],
|
|
content = "",
|
|
} = params || {};
|
|
const schema = getNostrEventSchema(NOTE_TYPES.VIDEO_MIRROR);
|
|
const combinedTags = [];
|
|
appendSchemaTags(combinedTags, schema);
|
|
if (Array.isArray(tags)) {
|
|
tags.forEach((tag) => {
|
|
if (Array.isArray(tag) && tag.length >= 2) {
|
|
combinedTags.push(tag.map((value) => (typeof value === "string" ? value : String(value))));
|
|
}
|
|
});
|
|
}
|
|
const event = {
|
|
kind: schema?.kind ?? 1063,
|
|
pubkey,
|
|
created_at,
|
|
tags: combinedTags,
|
|
content: safeStringify(content),
|
|
};
|
|
|
|
if (isDevMode) {
|
|
validateEventAgainstSchema(NOTE_TYPES.VIDEO_MIRROR, event);
|
|
}
|
|
|
|
return event;
|
|
}
|
|
|
|
export function buildRepostEvent(params) {
|
|
const {
|
|
pubkey,
|
|
created_at,
|
|
eventId = "",
|
|
eventRelay = "",
|
|
address = "",
|
|
addressRelay = "",
|
|
authorPubkey = "",
|
|
additionalTags = [],
|
|
repostKind,
|
|
targetKind,
|
|
targetEvent = null,
|
|
serializedEvent = "",
|
|
} = params || {};
|
|
const schema = getNostrEventSchema(NOTE_TYPES.REPOST);
|
|
const tags = [];
|
|
|
|
const normalizedEventId = typeof eventId === "string" ? eventId.trim() : "";
|
|
const normalizedEventRelay =
|
|
typeof eventRelay === "string" ? eventRelay.trim() : "";
|
|
|
|
if (normalizedEventId) {
|
|
if (normalizedEventRelay) {
|
|
tags.push(["e", normalizedEventId, normalizedEventRelay]);
|
|
} else {
|
|
tags.push(["e", normalizedEventId]);
|
|
}
|
|
}
|
|
|
|
const normalizedAddress = typeof address === "string" ? address.trim() : "";
|
|
const normalizedAddressRelay = typeof addressRelay === "string" ? addressRelay.trim() : "";
|
|
if (normalizedAddress) {
|
|
if (normalizedAddressRelay) {
|
|
tags.push(["a", normalizedAddress, normalizedAddressRelay]);
|
|
} else {
|
|
tags.push(["a", normalizedAddress]);
|
|
}
|
|
}
|
|
|
|
const normalizedAuthorPubkey =
|
|
typeof authorPubkey === "string" ? authorPubkey.trim().toLowerCase() : "";
|
|
if (normalizedAuthorPubkey) {
|
|
tags.push(["p", normalizedAuthorPubkey]);
|
|
}
|
|
|
|
appendSchemaTags(tags, schema);
|
|
const sanitizedAdditionalTags = sanitizeAdditionalTags(additionalTags);
|
|
if (sanitizedAdditionalTags.length) {
|
|
tags.push(...sanitizedAdditionalTags.map((tag) => tag.slice()));
|
|
}
|
|
|
|
const normalizedRepostKind =
|
|
Number.isFinite(repostKind) && repostKind !== null
|
|
? Math.floor(repostKind)
|
|
: null;
|
|
const normalizedTargetKind =
|
|
Number.isFinite(targetKind) && targetKind !== null
|
|
? Math.floor(targetKind)
|
|
: null;
|
|
const resolvedKind = (() => {
|
|
if (normalizedRepostKind) {
|
|
return normalizedRepostKind;
|
|
}
|
|
if (normalizedTargetKind !== null) {
|
|
return normalizedTargetKind === 1 ? schema?.kind ?? 6 : 16;
|
|
}
|
|
return schema?.kind ?? 6;
|
|
})();
|
|
|
|
if (resolvedKind === 16 && normalizedTargetKind !== null) {
|
|
tags.push(["k", String(normalizedTargetKind)]);
|
|
}
|
|
|
|
const normalizedSerializedEvent =
|
|
typeof serializedEvent === "string" ? serializedEvent : "";
|
|
let normalizedTargetEvent =
|
|
targetEvent && typeof targetEvent === "object" ? targetEvent : null;
|
|
|
|
if (!normalizedTargetEvent && normalizedSerializedEvent) {
|
|
try {
|
|
normalizedTargetEvent = JSON.parse(normalizedSerializedEvent);
|
|
} catch (error) {
|
|
void error;
|
|
}
|
|
}
|
|
|
|
const hasProtectedTag = Array.isArray(normalizedTargetEvent?.tags)
|
|
? normalizedTargetEvent.tags.some(
|
|
(tag) => Array.isArray(tag) && tag.length && tag[0] === "-",
|
|
)
|
|
: false;
|
|
|
|
let content = "";
|
|
|
|
if (!hasProtectedTag) {
|
|
if (normalizedSerializedEvent) {
|
|
content = normalizedSerializedEvent;
|
|
} else if (normalizedTargetEvent) {
|
|
try {
|
|
content = JSON.stringify(normalizedTargetEvent);
|
|
} catch (error) {
|
|
void error;
|
|
}
|
|
}
|
|
}
|
|
|
|
const event = {
|
|
kind: resolvedKind,
|
|
pubkey,
|
|
created_at,
|
|
tags,
|
|
content,
|
|
};
|
|
|
|
if (isDevMode) {
|
|
validateEventAgainstSchema(NOTE_TYPES.REPOST, event);
|
|
}
|
|
|
|
return event;
|
|
}
|
|
|
|
export function buildShareEvent(params) {
|
|
const {
|
|
pubkey,
|
|
created_at,
|
|
content = "",
|
|
video = null,
|
|
relays = [],
|
|
additionalTags = [],
|
|
} = params || {};
|
|
const schema = getNostrEventSchema(NOTE_TYPES.SHARE);
|
|
const tags = [];
|
|
const relayTagName = schema?.relayTagName || "r";
|
|
const readMarker = schema?.readMarker || "read";
|
|
const writeMarker = schema?.writeMarker || "write";
|
|
|
|
const normalizedVideoId = normalizePointerIdentifier(video?.id);
|
|
if (normalizedVideoId) {
|
|
tags.push(["e", normalizedVideoId, "", "mention"]);
|
|
}
|
|
|
|
const normalizedVideoPubkey = normalizePointerIdentifier(video?.pubkey);
|
|
if (normalizedVideoPubkey) {
|
|
tags.push(["p", normalizedVideoPubkey, "", "mention"]);
|
|
}
|
|
|
|
const seenRelayTags = new Set();
|
|
|
|
const pushRelayTag = (url, marker) => {
|
|
const normalizedUrl = typeof url === "string" ? url.trim() : "";
|
|
if (!normalizedUrl) {
|
|
return;
|
|
}
|
|
const normalizedMarker =
|
|
typeof marker === "string" ? marker.trim().toLowerCase() : "";
|
|
const resolvedMarker =
|
|
normalizedMarker === "read" || normalizedMarker === "write"
|
|
? normalizedMarker
|
|
: "";
|
|
const dedupeKey = `${normalizedUrl}|${resolvedMarker}`;
|
|
if (seenRelayTags.has(dedupeKey)) {
|
|
return;
|
|
}
|
|
seenRelayTags.add(dedupeKey);
|
|
if (resolvedMarker) {
|
|
const markerValue = resolvedMarker === "read" ? readMarker : writeMarker;
|
|
tags.push([relayTagName, normalizedUrl, markerValue]);
|
|
} else {
|
|
tags.push([relayTagName, normalizedUrl]);
|
|
}
|
|
};
|
|
|
|
if (Array.isArray(relays)) {
|
|
relays.forEach((entry) => {
|
|
if (typeof entry === "string") {
|
|
pushRelayTag(entry, "");
|
|
return;
|
|
}
|
|
|
|
if (Array.isArray(entry) && entry.length) {
|
|
if (entry[0] === relayTagName) {
|
|
pushRelayTag(entry[1], entry[2]);
|
|
return;
|
|
}
|
|
pushRelayTag(entry[0], entry[1]);
|
|
return;
|
|
}
|
|
|
|
if (entry && typeof entry === "object") {
|
|
const url =
|
|
typeof entry.url === "string"
|
|
? entry.url
|
|
: typeof entry.relay === "string"
|
|
? entry.relay
|
|
: "";
|
|
let marker = "";
|
|
if (typeof entry.mode === "string") {
|
|
marker = entry.mode;
|
|
} else if (typeof entry.marker === "string") {
|
|
marker = entry.marker;
|
|
} else if (entry.read === true && entry.write === false) {
|
|
marker = "read";
|
|
} else if (entry.write === true && entry.read === false) {
|
|
marker = "write";
|
|
}
|
|
pushRelayTag(url, marker);
|
|
}
|
|
});
|
|
}
|
|
|
|
appendSchemaTags(tags, schema);
|
|
|
|
const sanitizedAdditionalTags = sanitizeAdditionalTags(additionalTags);
|
|
if (sanitizedAdditionalTags.length) {
|
|
tags.push(...sanitizedAdditionalTags.map((tag) => tag.slice()));
|
|
}
|
|
|
|
const resolvedContent = ensureValidUtf8Content(content);
|
|
|
|
const event = {
|
|
kind: schema?.kind ?? 1,
|
|
pubkey,
|
|
created_at,
|
|
tags,
|
|
content: resolvedContent,
|
|
};
|
|
|
|
if (isDevMode) {
|
|
validateEventAgainstSchema(NOTE_TYPES.SHARE, event);
|
|
}
|
|
|
|
return event;
|
|
}
|
|
|
|
export function buildRelayListEvent(params) {
|
|
const {
|
|
pubkey,
|
|
created_at,
|
|
relays = [],
|
|
additionalTags = [],
|
|
} = params || {};
|
|
const schema = getNostrEventSchema(NOTE_TYPES.RELAY_LIST);
|
|
const tags = [];
|
|
const relayTagName = schema?.relayTagName || "r";
|
|
const readMarker = schema?.readMarker || "read";
|
|
const writeMarker = schema?.writeMarker || "write";
|
|
|
|
if (Array.isArray(relays)) {
|
|
relays.forEach((entry) => {
|
|
let url = "";
|
|
let mode = "";
|
|
if (typeof entry === "string") {
|
|
url = entry;
|
|
} else if (entry && typeof entry === "object") {
|
|
if (typeof entry.url === "string") {
|
|
url = entry.url;
|
|
}
|
|
if (typeof entry.mode === "string") {
|
|
mode = entry.mode;
|
|
} else if (typeof entry.marker === "string") {
|
|
mode = entry.marker;
|
|
} else if (entry.read === true && entry.write === false) {
|
|
mode = "read";
|
|
} else if (entry.write === true && entry.read === false) {
|
|
mode = "write";
|
|
}
|
|
} else if (Array.isArray(entry) && entry.length >= 1) {
|
|
url = typeof entry[0] === "string" ? entry[0] : String(entry[0]);
|
|
if (entry.length > 1 && typeof entry[1] === "string") {
|
|
mode = entry[1];
|
|
}
|
|
}
|
|
|
|
const normalizedUrl = typeof url === "string" ? url.trim() : "";
|
|
if (!normalizedUrl) {
|
|
return;
|
|
}
|
|
|
|
const normalizedMode = typeof mode === "string" ? mode.trim().toLowerCase() : "";
|
|
if (normalizedMode === "read") {
|
|
tags.push([relayTagName, normalizedUrl, readMarker]);
|
|
} else if (normalizedMode === "write") {
|
|
tags.push([relayTagName, normalizedUrl, writeMarker]);
|
|
} else {
|
|
tags.push([relayTagName, normalizedUrl]);
|
|
}
|
|
});
|
|
}
|
|
|
|
appendSchemaTags(tags, schema);
|
|
|
|
const sanitizedAdditionalTags = sanitizeAdditionalTags(additionalTags);
|
|
if (sanitizedAdditionalTags.length) {
|
|
tags.push(...sanitizedAdditionalTags.map((tag) => tag.slice()));
|
|
}
|
|
|
|
const event = {
|
|
kind: schema?.kind ?? 10002,
|
|
pubkey,
|
|
created_at,
|
|
tags,
|
|
content: "",
|
|
};
|
|
|
|
if (isDevMode) {
|
|
validateEventAgainstSchema(NOTE_TYPES.RELAY_LIST, event);
|
|
}
|
|
|
|
return event;
|
|
}
|
|
|
|
export function buildDmRelayListEvent(params) {
|
|
const {
|
|
pubkey,
|
|
created_at,
|
|
relays = [],
|
|
additionalTags = [],
|
|
} = params || {};
|
|
const schema = getNostrEventSchema(NOTE_TYPES.DM_RELAY_LIST);
|
|
const tags = [];
|
|
const relayTagName = schema?.relayTagName || "relay";
|
|
|
|
if (Array.isArray(relays)) {
|
|
relays.forEach((relay) => {
|
|
const normalized = typeof relay === "string" ? relay.trim() : "";
|
|
if (normalized) {
|
|
tags.push([relayTagName, normalized]);
|
|
}
|
|
});
|
|
}
|
|
|
|
appendSchemaTags(tags, schema);
|
|
|
|
const sanitizedAdditionalTags = sanitizeAdditionalTags(additionalTags);
|
|
if (sanitizedAdditionalTags.length) {
|
|
tags.push(...sanitizedAdditionalTags.map((tag) => tag.slice()));
|
|
}
|
|
|
|
const event = {
|
|
kind: schema?.kind ?? 10050,
|
|
pubkey,
|
|
created_at,
|
|
tags,
|
|
content: "",
|
|
};
|
|
|
|
if (isDevMode) {
|
|
validateEventAgainstSchema(NOTE_TYPES.DM_RELAY_LIST, event);
|
|
}
|
|
|
|
return event;
|
|
}
|
|
|
|
export function buildProfileMetadataEvent(params) {
|
|
const {
|
|
pubkey,
|
|
created_at,
|
|
metadata = {},
|
|
content,
|
|
additionalTags = [],
|
|
} = params || {};
|
|
const schema = getNostrEventSchema(NOTE_TYPES.PROFILE_METADATA);
|
|
const tags = [];
|
|
appendSchemaTags(tags, schema);
|
|
const sanitizedAdditionalTags = sanitizeAdditionalTags(additionalTags);
|
|
if (sanitizedAdditionalTags.length) {
|
|
tags.push(...sanitizedAdditionalTags.map((tag) => tag.slice()));
|
|
}
|
|
|
|
let serializedContent = "";
|
|
if (typeof content === "string") {
|
|
serializedContent = content;
|
|
} else {
|
|
try {
|
|
serializedContent = JSON.stringify(metadata || {});
|
|
} catch (error) {
|
|
serializedContent = "{}";
|
|
}
|
|
}
|
|
|
|
const event = {
|
|
kind: schema?.kind ?? 0,
|
|
pubkey,
|
|
created_at,
|
|
tags,
|
|
content: serializedContent,
|
|
};
|
|
|
|
if (isDevMode) {
|
|
validateEventAgainstSchema(NOTE_TYPES.PROFILE_METADATA, event);
|
|
}
|
|
|
|
return event;
|
|
}
|
|
|
|
export function buildMuteListEvent(params) {
|
|
const {
|
|
pubkey,
|
|
created_at,
|
|
pTags = [],
|
|
content = "",
|
|
encrypted = false,
|
|
encryptionTag = "",
|
|
additionalTags = [],
|
|
} = params || {};
|
|
const schema = getNostrEventSchema(NOTE_TYPES.MUTE_LIST);
|
|
const tags = [];
|
|
|
|
const participantTagName = schema?.participantTagName || "p";
|
|
if (Array.isArray(pTags)) {
|
|
pTags.forEach((hex) => {
|
|
if (typeof hex === "string" && hex.trim()) {
|
|
tags.push([participantTagName, hex.trim()]);
|
|
}
|
|
});
|
|
}
|
|
|
|
if (encrypted && encryptionTag) {
|
|
tags.push(["encrypted", encryptionTag]);
|
|
}
|
|
|
|
appendSchemaTags(tags, schema);
|
|
const sanitizedAdditionalTags = sanitizeAdditionalTags(additionalTags);
|
|
if (sanitizedAdditionalTags.length) {
|
|
tags.push(...sanitizedAdditionalTags.map((tag) => tag.slice()));
|
|
}
|
|
|
|
const event = {
|
|
kind: schema?.kind ?? 10000,
|
|
pubkey,
|
|
created_at,
|
|
tags,
|
|
content: typeof content === "string" ? content : "",
|
|
};
|
|
|
|
if (isDevMode) {
|
|
validateEventAgainstSchema(NOTE_TYPES.MUTE_LIST, event);
|
|
}
|
|
|
|
return event;
|
|
}
|
|
|
|
export function buildDeletionEvent(params) {
|
|
const {
|
|
pubkey,
|
|
created_at,
|
|
eventIds = [],
|
|
addresses = [],
|
|
reason = "",
|
|
additionalTags = [],
|
|
} = params || {};
|
|
const schema = getNostrEventSchema(NOTE_TYPES.DELETION);
|
|
const tags = [];
|
|
|
|
if (Array.isArray(eventIds)) {
|
|
eventIds.forEach((id) => {
|
|
const normalized = normalizePointerIdentifier(id);
|
|
if (normalized) {
|
|
tags.push(["e", normalized]);
|
|
}
|
|
});
|
|
}
|
|
|
|
if (Array.isArray(addresses)) {
|
|
addresses.forEach((addr) => {
|
|
const normalized = typeof addr === "string" ? addr.trim() : "";
|
|
if (normalized) {
|
|
tags.push(["a", normalized]);
|
|
}
|
|
});
|
|
}
|
|
|
|
appendSchemaTags(tags, schema);
|
|
const sanitizedAdditionalTags = sanitizeAdditionalTags(additionalTags);
|
|
if (sanitizedAdditionalTags.length) {
|
|
tags.push(...sanitizedAdditionalTags.map((tag) => tag.slice()));
|
|
}
|
|
|
|
const event = {
|
|
kind: schema?.kind ?? 5,
|
|
pubkey,
|
|
created_at,
|
|
tags,
|
|
content: typeof reason === "string" ? reason : "",
|
|
};
|
|
|
|
if (isDevMode) {
|
|
validateEventAgainstSchema(NOTE_TYPES.DELETION, event);
|
|
}
|
|
|
|
return event;
|
|
}
|
|
|
|
export function buildLegacyDirectMessageEvent(params) {
|
|
const {
|
|
pubkey,
|
|
created_at,
|
|
recipientPubkey,
|
|
ciphertext = "",
|
|
additionalTags = [],
|
|
} = params || {};
|
|
const schema = getNostrEventSchema(NOTE_TYPES.LEGACY_DM);
|
|
const tags = [];
|
|
|
|
const recipient = normalizePointerIdentifier(recipientPubkey);
|
|
if (recipient) {
|
|
tags.push([schema?.recipientTagName || "p", recipient]);
|
|
}
|
|
|
|
appendSchemaTags(tags, schema);
|
|
const sanitizedAdditionalTags = sanitizeAdditionalTags(additionalTags);
|
|
if (sanitizedAdditionalTags.length) {
|
|
tags.push(...sanitizedAdditionalTags.map((tag) => tag.slice()));
|
|
}
|
|
|
|
const event = {
|
|
kind: schema?.kind ?? 4,
|
|
pubkey,
|
|
created_at,
|
|
tags,
|
|
content: typeof ciphertext === "string" ? ciphertext : "",
|
|
};
|
|
|
|
if (isDevMode) {
|
|
validateEventAgainstSchema(NOTE_TYPES.LEGACY_DM, event);
|
|
}
|
|
|
|
return event;
|
|
}
|
|
|
|
export function buildDmAttachmentEvent(params) {
|
|
const {
|
|
pubkey,
|
|
created_at,
|
|
recipientPubkey,
|
|
attachment = {},
|
|
additionalTags = [],
|
|
} = params || {};
|
|
const schema = getNostrEventSchema(NOTE_TYPES.DM_ATTACHMENT);
|
|
const tags = [];
|
|
|
|
const participantTagName = schema?.participantTagName || "p";
|
|
if (recipientPubkey && typeof recipientPubkey === "string") {
|
|
tags.push([participantTagName, recipientPubkey.trim()]);
|
|
}
|
|
|
|
if (attachment?.x) {
|
|
tags.push([schema?.hashTagName || "x", attachment.x]);
|
|
}
|
|
if (attachment?.url) {
|
|
tags.push([schema?.urlTagName || "url", attachment.url]);
|
|
}
|
|
if (attachment?.name) {
|
|
tags.push([schema?.nameTagName || "name", attachment.name]);
|
|
}
|
|
if (attachment?.type) {
|
|
tags.push([schema?.typeTagName || "type", attachment.type]);
|
|
}
|
|
if (Number.isFinite(attachment?.size)) {
|
|
tags.push([schema?.sizeTagName || "size", String(attachment.size)]);
|
|
}
|
|
if (attachment?.key) {
|
|
tags.push([schema?.keyTagName || "k", attachment.key]);
|
|
}
|
|
|
|
appendSchemaTags(tags, schema);
|
|
const sanitizedAdditionalTags = sanitizeAdditionalTags(additionalTags);
|
|
if (sanitizedAdditionalTags.length) {
|
|
tags.push(...sanitizedAdditionalTags.map((tag) => tag.slice()));
|
|
}
|
|
|
|
const event = {
|
|
kind: schema?.kind ?? 15,
|
|
pubkey,
|
|
created_at,
|
|
tags,
|
|
content: "",
|
|
};
|
|
|
|
if (isDevMode) {
|
|
validateEventAgainstSchema(NOTE_TYPES.DM_ATTACHMENT, event);
|
|
}
|
|
|
|
return event;
|
|
}
|
|
|
|
export function buildDmReadReceiptEvent(params) {
|
|
const {
|
|
pubkey,
|
|
created_at,
|
|
recipientPubkey,
|
|
eventId,
|
|
messageKind,
|
|
additionalTags = [],
|
|
} = params || {};
|
|
const schema = getNostrEventSchema(NOTE_TYPES.DM_READ_RECEIPT);
|
|
const tags = [];
|
|
|
|
const recipient = normalizePointerIdentifier(recipientPubkey);
|
|
if (recipient) {
|
|
tags.push([schema?.recipientTagName || "p", recipient]);
|
|
}
|
|
|
|
const normalizedEventId = normalizePointerIdentifier(eventId);
|
|
if (normalizedEventId) {
|
|
tags.push([schema?.eventTagName || "e", normalizedEventId]);
|
|
}
|
|
|
|
if (Number.isFinite(messageKind)) {
|
|
tags.push([schema?.kindTagName || "k", String(Math.floor(messageKind))]);
|
|
}
|
|
|
|
appendSchemaTags(tags, schema);
|
|
const sanitizedAdditionalTags = sanitizeAdditionalTags(additionalTags);
|
|
if (sanitizedAdditionalTags.length) {
|
|
tags.push(...sanitizedAdditionalTags.map((tag) => tag.slice()));
|
|
}
|
|
|
|
const event = {
|
|
kind: schema?.kind ?? 20001,
|
|
pubkey,
|
|
created_at,
|
|
tags,
|
|
content: "",
|
|
};
|
|
|
|
if (isDevMode) {
|
|
validateEventAgainstSchema(NOTE_TYPES.DM_READ_RECEIPT, event);
|
|
}
|
|
|
|
return event;
|
|
}
|
|
|
|
export function buildDmTypingIndicatorEvent(params) {
|
|
const {
|
|
pubkey,
|
|
created_at,
|
|
recipientPubkey,
|
|
eventId,
|
|
expiresAt,
|
|
additionalTags = [],
|
|
} = params || {};
|
|
const schema = getNostrEventSchema(NOTE_TYPES.DM_TYPING);
|
|
const tags = [];
|
|
|
|
const recipient = normalizePointerIdentifier(recipientPubkey);
|
|
if (recipient) {
|
|
tags.push([schema?.recipientTagName || "p", recipient]);
|
|
}
|
|
|
|
const normalizedEventId = normalizePointerIdentifier(eventId);
|
|
if (normalizedEventId) {
|
|
tags.push([schema?.eventTagName || "e", normalizedEventId]);
|
|
}
|
|
|
|
if (schema?.statusTagName && schema?.statusTagValue) {
|
|
tags.push([schema.statusTagName, schema.statusTagValue]);
|
|
}
|
|
|
|
if (Number.isFinite(expiresAt)) {
|
|
tags.push([schema?.expirationTagName || "expiration", String(Math.floor(expiresAt))]);
|
|
}
|
|
|
|
appendSchemaTags(tags, schema);
|
|
const sanitizedAdditionalTags = sanitizeAdditionalTags(additionalTags);
|
|
if (sanitizedAdditionalTags.length) {
|
|
tags.push(...sanitizedAdditionalTags.map((tag) => tag.slice()));
|
|
}
|
|
|
|
const event = {
|
|
kind: schema?.kind ?? 20002,
|
|
pubkey,
|
|
created_at,
|
|
tags,
|
|
content: "",
|
|
};
|
|
|
|
if (isDevMode) {
|
|
validateEventAgainstSchema(NOTE_TYPES.DM_TYPING, event);
|
|
}
|
|
|
|
return event;
|
|
}
|
|
|
|
export function buildViewEvent(params) {
|
|
const {
|
|
pubkey,
|
|
created_at,
|
|
pointerValue,
|
|
pointerTag,
|
|
pointerTags = [],
|
|
dedupeTag,
|
|
includeSessionTag = false,
|
|
additionalTags = [],
|
|
content = "",
|
|
} = params || {};
|
|
const schema = getNostrEventSchema(NOTE_TYPES.VIEW_EVENT);
|
|
const tags = [];
|
|
if (schema?.topicTag?.name && schema?.topicTag?.value) {
|
|
tags.push([schema.topicTag.name, schema.topicTag.value]);
|
|
}
|
|
|
|
const normalizedPointerTags = collectPointerTags(schema, {
|
|
pointerValue,
|
|
pointerTag,
|
|
pointerTags,
|
|
});
|
|
normalizedPointerTags.forEach((tag) => {
|
|
tags.push(tag);
|
|
});
|
|
const sanitizedAdditionalTags = sanitizeAdditionalTags(additionalTags);
|
|
if (sanitizedAdditionalTags.length) {
|
|
tags.push(...sanitizedAdditionalTags.map((tag) => tag.slice()));
|
|
}
|
|
|
|
if (dedupeTag && schema?.identifierTag?.name) {
|
|
const identifierName = schema.identifierTag.name;
|
|
const hasDedupe = tags.some(
|
|
(tag) => tag[0] === identifierName && tag[1] === dedupeTag
|
|
);
|
|
if (!hasDedupe) {
|
|
tags.push([identifierName, dedupeTag]);
|
|
}
|
|
}
|
|
if (includeSessionTag && schema?.sessionTag?.name && schema?.sessionTag?.value) {
|
|
const hasSession = tags.some(
|
|
(tag) => tag[0] === schema.sessionTag.name && tag[1] === schema.sessionTag.value
|
|
);
|
|
if (!hasSession) {
|
|
tags.push([schema.sessionTag.name, schema.sessionTag.value]);
|
|
}
|
|
}
|
|
appendSchemaTags(tags, schema);
|
|
|
|
const resolvedContent = ensureValidUtf8Content(content);
|
|
|
|
const event = {
|
|
kind: schema?.kind ?? WATCH_HISTORY_KIND,
|
|
pubkey,
|
|
created_at,
|
|
tags,
|
|
content: resolvedContent,
|
|
};
|
|
|
|
if (isDevMode) {
|
|
validateEventAgainstSchema(NOTE_TYPES.VIEW_EVENT, event);
|
|
}
|
|
|
|
return event;
|
|
}
|
|
|
|
export function buildZapRequestEvent(params) {
|
|
const {
|
|
pubkey,
|
|
created_at,
|
|
recipientPubkey,
|
|
relays = [],
|
|
amountSats,
|
|
lnurl,
|
|
eventId,
|
|
coordinate,
|
|
additionalTags = [],
|
|
content = "",
|
|
} = params || {};
|
|
const schema = getNostrEventSchema(NOTE_TYPES.ZAP_REQUEST);
|
|
const tags = [];
|
|
|
|
const normalizedRecipient = normalizePointerIdentifier(recipientPubkey);
|
|
if (normalizedRecipient) {
|
|
tags.push(["p", normalizedRecipient]);
|
|
}
|
|
|
|
const normalizedEventId = normalizePointerIdentifier(eventId);
|
|
if (normalizedEventId) {
|
|
tags.push(["e", normalizedEventId]);
|
|
}
|
|
|
|
if (typeof coordinate === "string" && coordinate.trim()) {
|
|
tags.push(["a", coordinate.trim()]);
|
|
}
|
|
|
|
if (typeof lnurl === "string" && lnurl.trim()) {
|
|
tags.push(["lnurl", lnurl.trim()]);
|
|
}
|
|
|
|
if (Number.isFinite(amountSats)) {
|
|
tags.push(["amount", String(Math.max(0, Math.round(amountSats)) * 1000)]);
|
|
}
|
|
|
|
const relayList = Array.isArray(relays) ? relays : [];
|
|
const relaySeen = new Set();
|
|
const normalizedRelays = [];
|
|
relayList.forEach((relay) => {
|
|
if (typeof relay !== "string") {
|
|
return;
|
|
}
|
|
const trimmed = relay.trim();
|
|
if (!trimmed || relaySeen.has(trimmed)) {
|
|
return;
|
|
}
|
|
relaySeen.add(trimmed);
|
|
normalizedRelays.push(trimmed);
|
|
});
|
|
if (normalizedRelays.length) {
|
|
tags.push(["relays", ...normalizedRelays]);
|
|
}
|
|
|
|
const sanitizedAdditionalTags = sanitizeAdditionalTags(additionalTags);
|
|
if (sanitizedAdditionalTags.length) {
|
|
tags.push(...sanitizedAdditionalTags.map((tag) => tag.slice()));
|
|
}
|
|
|
|
appendSchemaTags(tags, schema);
|
|
|
|
const resolvedContent = ensureValidUtf8Content(content);
|
|
|
|
const event = {
|
|
kind: schema?.kind ?? 9734,
|
|
pubkey,
|
|
created_at,
|
|
tags,
|
|
content: resolvedContent,
|
|
};
|
|
|
|
if (isDevMode) {
|
|
validateEventAgainstSchema(NOTE_TYPES.ZAP_REQUEST, event);
|
|
}
|
|
|
|
return event;
|
|
}
|
|
|
|
export function buildReactionEvent(params) {
|
|
const {
|
|
pubkey,
|
|
created_at,
|
|
pointerValue,
|
|
pointerTag,
|
|
pointerTags = [],
|
|
targetPointer = null,
|
|
targetAuthorPubkey = "",
|
|
additionalTags = [],
|
|
content = "",
|
|
} = params || {};
|
|
const schema = getNostrEventSchema(NOTE_TYPES.VIDEO_REACTION);
|
|
const tags = [];
|
|
|
|
const normalizedPointerTags = collectPointerTags(schema, {
|
|
pointerValue,
|
|
pointerTag,
|
|
pointerTags,
|
|
});
|
|
const mergedPointerTags = mergePointerTags(normalizedPointerTags);
|
|
mergedPointerTags.forEach((tag) => {
|
|
if (Array.isArray(tag) && tag.length >= 2) {
|
|
tags.push(tag);
|
|
}
|
|
});
|
|
|
|
const resolvePointerDetails = (pointerCandidate) => {
|
|
if (!pointerCandidate) {
|
|
return null;
|
|
}
|
|
|
|
if (Array.isArray(pointerCandidate) && pointerCandidate.length >= 2) {
|
|
const [type, value, relay] = pointerCandidate;
|
|
const normalizedType = type === "a" ? "a" : type === "e" ? "e" : "";
|
|
const normalizedValue = typeof value === "string" ? value.trim() : "";
|
|
if (!normalizedType || !normalizedValue) {
|
|
return null;
|
|
}
|
|
const normalizedRelay =
|
|
typeof relay === "string" && relay.trim() ? relay.trim() : "";
|
|
return { type: normalizedType, value: normalizedValue, relay: normalizedRelay };
|
|
}
|
|
|
|
if (pointerCandidate && typeof pointerCandidate === "object") {
|
|
const { type, value, relay } = pointerCandidate;
|
|
const normalizedType = type === "a" ? "a" : type === "e" ? "e" : "";
|
|
const normalizedValue = typeof value === "string" ? value.trim() : "";
|
|
if (!normalizedType || !normalizedValue) {
|
|
return null;
|
|
}
|
|
const normalizedRelay =
|
|
typeof relay === "string" && relay.trim() ? relay.trim() : "";
|
|
return { type: normalizedType, value: normalizedValue, relay: normalizedRelay };
|
|
}
|
|
|
|
return null;
|
|
};
|
|
|
|
const pointerDetails = (() => {
|
|
const explicitPointer = resolvePointerDetails(targetPointer);
|
|
if (explicitPointer) {
|
|
return explicitPointer;
|
|
}
|
|
|
|
const pointerTagEntry = mergedPointerTags.find(
|
|
(tag) => Array.isArray(tag) && tag.length >= 2 && (tag[0] === "a" || tag[0] === "e")
|
|
);
|
|
if (!pointerTagEntry) {
|
|
return null;
|
|
}
|
|
|
|
return resolvePointerDetails(pointerTagEntry);
|
|
})();
|
|
|
|
const resolvedRelay = pointerDetails?.relay || "";
|
|
|
|
const normalizedAuthorPubkey = (() => {
|
|
if (typeof targetAuthorPubkey === "string" && targetAuthorPubkey.trim()) {
|
|
return targetAuthorPubkey.trim();
|
|
}
|
|
|
|
if (pointerDetails?.type === "a" && pointerDetails.value) {
|
|
const segments = pointerDetails.value.split(":");
|
|
if (segments.length >= 2) {
|
|
const candidate = segments[1]?.trim();
|
|
if (candidate) {
|
|
return candidate;
|
|
}
|
|
}
|
|
}
|
|
|
|
return "";
|
|
})();
|
|
|
|
if (normalizedAuthorPubkey) {
|
|
const existingAuthorTag = tags.find(
|
|
(tag) => Array.isArray(tag) && tag[0] === "p" && tag[1] === normalizedAuthorPubkey
|
|
);
|
|
if (!existingAuthorTag) {
|
|
if (resolvedRelay) {
|
|
tags.push(["p", normalizedAuthorPubkey, resolvedRelay]);
|
|
} else {
|
|
tags.push(["p", normalizedAuthorPubkey]);
|
|
}
|
|
} else if (resolvedRelay) {
|
|
if (existingAuthorTag.length < 3) {
|
|
existingAuthorTag.push(resolvedRelay);
|
|
} else if (existingAuthorTag[2] !== resolvedRelay) {
|
|
existingAuthorTag[2] = resolvedRelay;
|
|
}
|
|
}
|
|
}
|
|
|
|
const sanitizedAdditionalTags = sanitizeAdditionalTags(additionalTags);
|
|
if (sanitizedAdditionalTags.length) {
|
|
tags.push(...sanitizedAdditionalTags.map((tag) => tag.slice()));
|
|
}
|
|
|
|
appendSchemaTags(tags, schema);
|
|
|
|
const resolvedContent = ensureValidUtf8Content(content);
|
|
|
|
const event = {
|
|
kind: schema?.kind ?? 7,
|
|
pubkey,
|
|
created_at,
|
|
tags,
|
|
content: resolvedContent,
|
|
};
|
|
|
|
if (isDevMode) {
|
|
validateEventAgainstSchema(NOTE_TYPES.VIDEO_REACTION, event);
|
|
}
|
|
|
|
return event;
|
|
}
|
|
|
|
export function buildCommentEvent(params) {
|
|
const {
|
|
pubkey,
|
|
created_at,
|
|
videoEventId = "",
|
|
videoEventRelay = "",
|
|
videoDefinitionAddress = "",
|
|
videoDefinitionRelay = "",
|
|
rootIdentifier = "",
|
|
rootIdentifierRelay = "",
|
|
parentCommentId = "",
|
|
parentCommentRelay = "",
|
|
threadParticipantPubkey = "",
|
|
threadParticipantRelay = "",
|
|
rootKind,
|
|
rootAuthorPubkey = "",
|
|
rootAuthorRelay = "",
|
|
parentKind,
|
|
parentAuthorPubkey = "",
|
|
parentAuthorRelay = "",
|
|
parentIdentifier = "",
|
|
parentIdentifierRelay = "",
|
|
additionalTags = [],
|
|
content = "",
|
|
} = params || {};
|
|
const schema = getNostrEventSchema(NOTE_TYPES.VIDEO_COMMENT);
|
|
const tags = [];
|
|
|
|
const normalizeString = (value) =>
|
|
typeof value === "string" ? value.trim() : "";
|
|
|
|
const normalizedVideoEventId = normalizeString(videoEventId);
|
|
const normalizedVideoEventRelay = normalizeString(videoEventRelay);
|
|
const normalizedVideoDefinitionAddress = normalizeString(videoDefinitionAddress);
|
|
const normalizedVideoDefinitionRelay = normalizeString(videoDefinitionRelay);
|
|
const normalizedRootIdentifier = normalizeString(rootIdentifier);
|
|
const normalizedRootIdentifierRelay = normalizeString(rootIdentifierRelay);
|
|
const normalizedParentCommentId = normalizeString(parentCommentId);
|
|
const normalizedParentCommentRelay = normalizeString(parentCommentRelay);
|
|
const normalizedParentIdentifier = normalizeString(parentIdentifier);
|
|
const normalizedParentIdentifierRelay = normalizeString(parentIdentifierRelay);
|
|
const normalizedThreadParticipantPubkey = normalizeString(
|
|
threadParticipantPubkey,
|
|
);
|
|
const normalizedThreadParticipantRelay = normalizeString(
|
|
threadParticipantRelay,
|
|
);
|
|
|
|
let resolvedRootKind = normalizeString(rootKind);
|
|
let resolvedRootAuthorPubkey = normalizeString(rootAuthorPubkey);
|
|
let resolvedRootAuthorRelay = normalizeString(rootAuthorRelay);
|
|
|
|
if (normalizedVideoDefinitionAddress) {
|
|
const definitionSegments = normalizedVideoDefinitionAddress.split(":");
|
|
if (!resolvedRootKind && definitionSegments[0]) {
|
|
resolvedRootKind = definitionSegments[0];
|
|
}
|
|
if (!resolvedRootAuthorPubkey && definitionSegments[1]) {
|
|
resolvedRootAuthorPubkey = definitionSegments[1];
|
|
}
|
|
if (!resolvedRootAuthorRelay && normalizedVideoDefinitionRelay) {
|
|
resolvedRootAuthorRelay = normalizedVideoDefinitionRelay;
|
|
}
|
|
}
|
|
|
|
let resolvedParentAuthorPubkey = normalizeString(parentAuthorPubkey);
|
|
if (!resolvedParentAuthorPubkey) {
|
|
resolvedParentAuthorPubkey = normalizedThreadParticipantPubkey;
|
|
}
|
|
|
|
let resolvedParentAuthorRelay = normalizeString(parentAuthorRelay);
|
|
if (!resolvedParentAuthorRelay) {
|
|
resolvedParentAuthorRelay = normalizedThreadParticipantRelay;
|
|
}
|
|
|
|
if (!resolvedRootAuthorPubkey && !normalizedParentCommentId) {
|
|
resolvedRootAuthorPubkey = resolvedParentAuthorPubkey;
|
|
}
|
|
if (!resolvedRootAuthorRelay && !normalizedParentCommentId) {
|
|
resolvedRootAuthorRelay = resolvedParentAuthorRelay;
|
|
}
|
|
|
|
if (!resolvedParentAuthorPubkey && !normalizedParentCommentId) {
|
|
resolvedParentAuthorPubkey = resolvedRootAuthorPubkey;
|
|
}
|
|
if (!resolvedParentAuthorRelay && !normalizedParentCommentId) {
|
|
resolvedParentAuthorRelay = resolvedRootAuthorRelay;
|
|
}
|
|
|
|
let resolvedParentKind = normalizeString(parentKind);
|
|
if (!resolvedParentKind) {
|
|
if (normalizedParentCommentId) {
|
|
resolvedParentKind = String(schema?.kind ?? 1111);
|
|
} else if (resolvedRootKind) {
|
|
resolvedParentKind = resolvedRootKind;
|
|
}
|
|
}
|
|
|
|
if (!resolvedRootKind) {
|
|
resolvedRootKind = resolvedParentKind;
|
|
}
|
|
|
|
const rootDefinitionPointerTagName = schema?.rootDefinitionPointerTagName || "A";
|
|
const rootEventPointerTagName = schema?.rootEventPointerTagName || "E";
|
|
const rootIdentifierPointerTagName = schema?.rootIdentifierPointerTagName || "I";
|
|
const rootKindTagName = schema?.rootKindTagName || "K";
|
|
const rootAuthorTagName = schema?.rootAuthorTagName || "P";
|
|
const parentIdentifierTagName = schema?.parentIdentifierTagName || "I";
|
|
const videoDefinitionTagName = schema?.videoDefinitionTagName || "A";
|
|
const videoEventTagName = schema?.videoEventTagName || "E";
|
|
const parentCommentTagName = schema?.parentCommentTagName || "E";
|
|
const parentAuthorTagName = schema?.parentAuthorTagName || "P";
|
|
const parentKindTagName = schema?.parentKindTagName || "K";
|
|
|
|
const appendPointerTag = (tagName, value, relay, authorHint) => {
|
|
if (!tagName || !value) {
|
|
return;
|
|
}
|
|
const tag = [tagName, value];
|
|
if (relay) {
|
|
tag.push(relay);
|
|
}
|
|
if (authorHint) {
|
|
tag.push(authorHint);
|
|
}
|
|
const isDuplicate = tags.some(
|
|
(existing) =>
|
|
existing.length === tag.length &&
|
|
existing.every((val, index) => val === tag[index]),
|
|
);
|
|
if (!isDuplicate) {
|
|
tags.push(tag);
|
|
}
|
|
};
|
|
|
|
if (normalizedRootIdentifier) {
|
|
appendPointerTag(
|
|
rootIdentifierPointerTagName,
|
|
normalizedRootIdentifier,
|
|
normalizedRootIdentifierRelay,
|
|
);
|
|
} else if (normalizedVideoDefinitionAddress) {
|
|
appendPointerTag(
|
|
rootDefinitionPointerTagName,
|
|
normalizedVideoDefinitionAddress,
|
|
normalizedVideoDefinitionRelay,
|
|
);
|
|
} else if (normalizedVideoEventId) {
|
|
appendPointerTag(
|
|
rootEventPointerTagName,
|
|
normalizedVideoEventId,
|
|
normalizedVideoEventRelay,
|
|
resolvedRootAuthorPubkey,
|
|
);
|
|
}
|
|
|
|
if (rootKindTagName && resolvedRootKind) {
|
|
tags.push([rootKindTagName, resolvedRootKind]);
|
|
}
|
|
|
|
if (rootAuthorTagName && resolvedRootAuthorPubkey) {
|
|
if (resolvedRootAuthorRelay) {
|
|
tags.push([rootAuthorTagName, resolvedRootAuthorPubkey, resolvedRootAuthorRelay]);
|
|
} else {
|
|
tags.push([rootAuthorTagName, resolvedRootAuthorPubkey]);
|
|
}
|
|
}
|
|
|
|
const shouldAppendVideoEventTag =
|
|
Boolean(videoEventTagName) && Boolean(normalizedVideoEventId);
|
|
|
|
if (shouldAppendVideoEventTag) {
|
|
appendPointerTag(
|
|
videoEventTagName,
|
|
normalizedVideoEventId,
|
|
normalizedVideoEventRelay,
|
|
);
|
|
}
|
|
|
|
if (videoDefinitionTagName && normalizedVideoDefinitionAddress) {
|
|
appendPointerTag(
|
|
videoDefinitionTagName,
|
|
normalizedVideoDefinitionAddress,
|
|
normalizedVideoDefinitionRelay,
|
|
);
|
|
}
|
|
|
|
if (normalizedParentIdentifier) {
|
|
appendPointerTag(
|
|
parentIdentifierTagName,
|
|
normalizedParentIdentifier,
|
|
normalizedParentIdentifierRelay,
|
|
);
|
|
}
|
|
|
|
if (parentCommentTagName && normalizedParentCommentId) {
|
|
appendPointerTag(
|
|
parentCommentTagName,
|
|
normalizedParentCommentId,
|
|
normalizedParentCommentRelay,
|
|
resolvedParentAuthorPubkey,
|
|
);
|
|
}
|
|
|
|
if (parentKindTagName && resolvedParentKind) {
|
|
tags.push([parentKindTagName, resolvedParentKind]);
|
|
}
|
|
|
|
if (parentAuthorTagName && resolvedParentAuthorPubkey) {
|
|
const existingParentAuthorTag = tags.find(
|
|
(tag) => Array.isArray(tag) && tag[0] === parentAuthorTagName && tag[1] === resolvedParentAuthorPubkey,
|
|
);
|
|
if (!existingParentAuthorTag) {
|
|
if (resolvedParentAuthorRelay) {
|
|
tags.push([parentAuthorTagName, resolvedParentAuthorPubkey, resolvedParentAuthorRelay]);
|
|
} else {
|
|
tags.push([parentAuthorTagName, resolvedParentAuthorPubkey]);
|
|
}
|
|
} else if (resolvedParentAuthorRelay) {
|
|
if (existingParentAuthorTag.length < 3) {
|
|
existingParentAuthorTag.push(resolvedParentAuthorRelay);
|
|
} else if (existingParentAuthorTag[2] !== resolvedParentAuthorRelay) {
|
|
existingParentAuthorTag[2] = resolvedParentAuthorRelay;
|
|
}
|
|
}
|
|
}
|
|
|
|
const sanitizedAdditionalTags = sanitizeAdditionalTags(additionalTags);
|
|
if (sanitizedAdditionalTags.length) {
|
|
tags.push(...sanitizedAdditionalTags.map((tag) => tag.slice()));
|
|
}
|
|
|
|
appendSchemaTags(tags, schema);
|
|
|
|
const resolvedContent = ensureValidUtf8Content(content);
|
|
|
|
const event = {
|
|
kind: schema?.kind ?? 1111,
|
|
pubkey,
|
|
created_at,
|
|
tags,
|
|
content: resolvedContent,
|
|
};
|
|
|
|
if (isDevMode) {
|
|
validateEventAgainstSchema(NOTE_TYPES.VIDEO_COMMENT, event);
|
|
}
|
|
|
|
return event;
|
|
}
|
|
|
|
export function buildWatchHistoryEvent(params) {
|
|
const {
|
|
pubkey,
|
|
created_at,
|
|
monthIdentifier,
|
|
pointerTags = [],
|
|
content,
|
|
} = params || {};
|
|
const schema = getNostrEventSchema(NOTE_TYPES.WATCH_HISTORY);
|
|
const tags = [];
|
|
const identifierName = schema?.identifierTag?.name || "d";
|
|
|
|
const identifierValue =
|
|
(typeof monthIdentifier === "string" && monthIdentifier.trim()) ||
|
|
schema?.identifierTag?.value;
|
|
|
|
if (identifierName && identifierValue) {
|
|
tags.push([identifierName, identifierValue]);
|
|
}
|
|
const monthTagName = schema?.monthTagName || "month";
|
|
if (monthTagName && identifierValue) {
|
|
tags.push([monthTagName, identifierValue]);
|
|
}
|
|
if (Array.isArray(pointerTags)) {
|
|
pointerTags.forEach((tag) => {
|
|
if (Array.isArray(tag) && tag.length >= 2) {
|
|
tags.push(tag.map((value) => (typeof value === "string" ? value : String(value))));
|
|
}
|
|
});
|
|
}
|
|
|
|
appendSchemaTags(tags, schema);
|
|
|
|
const event = {
|
|
kind: schema?.kind ?? WATCH_HISTORY_KIND,
|
|
pubkey,
|
|
created_at: Number.isFinite(created_at) ? created_at : Math.floor(Date.now() / 1000),
|
|
tags,
|
|
content: safeStringify(content),
|
|
};
|
|
|
|
if (isDevMode) {
|
|
validateEventAgainstSchema(NOTE_TYPES.WATCH_HISTORY, event);
|
|
}
|
|
|
|
return event;
|
|
}
|
|
|
|
export function buildSubscriptionListEvent(params) {
|
|
const {
|
|
pubkey,
|
|
created_at,
|
|
content,
|
|
encryption,
|
|
} = params || {};
|
|
const schema = getNostrEventSchema(NOTE_TYPES.SUBSCRIPTION_LIST);
|
|
const tags = [];
|
|
const identifierName = schema?.identifierTag?.name || "d";
|
|
const identifierValue = schema?.identifierTag?.value || SUBSCRIPTION_LIST_IDENTIFIER;
|
|
if (identifierName && identifierValue) {
|
|
tags.push([identifierName, identifierValue]);
|
|
}
|
|
const normalizedEncryption =
|
|
typeof encryption === "string" ? encryption.trim() : "";
|
|
if (normalizedEncryption) {
|
|
tags.push(["encrypted", normalizedEncryption]);
|
|
}
|
|
appendSchemaTags(tags, schema);
|
|
|
|
const event = {
|
|
kind: schema?.kind ?? 30000,
|
|
pubkey,
|
|
created_at,
|
|
tags,
|
|
content: safeStringify(content),
|
|
};
|
|
|
|
if (isDevMode) {
|
|
validateEventAgainstSchema(NOTE_TYPES.SUBSCRIPTION_LIST, event);
|
|
}
|
|
|
|
return event;
|
|
}
|
|
|
|
export function buildBlockListEvent(params) {
|
|
const {
|
|
pubkey,
|
|
created_at,
|
|
content,
|
|
encryption,
|
|
} = params || {};
|
|
const schema = getNostrEventSchema(NOTE_TYPES.USER_BLOCK_LIST);
|
|
const tags = [];
|
|
const identifierName = schema?.identifierTag?.name || "d";
|
|
const identifierValue = schema?.identifierTag?.value || BLOCK_LIST_IDENTIFIER;
|
|
if (identifierName && identifierValue) {
|
|
tags.push([identifierName, identifierValue]);
|
|
}
|
|
const normalizedEncryption =
|
|
typeof encryption === "string" ? encryption.trim() : "";
|
|
if (normalizedEncryption) {
|
|
tags.push(["encrypted", normalizedEncryption]);
|
|
}
|
|
appendSchemaTags(tags, schema);
|
|
|
|
const event = {
|
|
kind: schema?.kind ?? 10000,
|
|
pubkey,
|
|
created_at,
|
|
tags,
|
|
content: safeStringify(content),
|
|
};
|
|
|
|
if (isDevMode) {
|
|
validateEventAgainstSchema(NOTE_TYPES.USER_BLOCK_LIST, event);
|
|
}
|
|
|
|
return event;
|
|
}
|
|
|
|
export function buildHashtagPreferenceEvent(params) {
|
|
const {
|
|
pubkey,
|
|
created_at,
|
|
content,
|
|
} = params || {};
|
|
const schema = getNostrEventSchema(NOTE_TYPES.HASHTAG_PREFERENCES);
|
|
const tags = [];
|
|
const identifierName = schema?.identifierTag?.name || "d";
|
|
const identifierValue = schema?.identifierTag?.value || "bitvid:tag-preferences";
|
|
if (identifierName && identifierValue) {
|
|
tags.push([identifierName, identifierValue]);
|
|
}
|
|
appendSchemaTags(tags, schema);
|
|
|
|
const event = {
|
|
kind: schema?.kind ?? 30015,
|
|
pubkey,
|
|
created_at,
|
|
tags,
|
|
content: safeStringify(content),
|
|
};
|
|
|
|
if (isDevMode) {
|
|
validateEventAgainstSchema(NOTE_TYPES.HASHTAG_PREFERENCES, event);
|
|
}
|
|
|
|
return event;
|
|
}
|
|
|
|
function resolveAdminNoteType(listKey) {
|
|
switch (listKey) {
|
|
case "moderation":
|
|
case "editors":
|
|
return NOTE_TYPES.ADMIN_MODERATION_LIST;
|
|
case "whitelist":
|
|
return NOTE_TYPES.ADMIN_WHITELIST;
|
|
case "blacklist":
|
|
return NOTE_TYPES.ADMIN_BLACKLIST;
|
|
default:
|
|
return null;
|
|
}
|
|
}
|
|
|
|
export function buildAdminListEvent(listKey, params) {
|
|
const {
|
|
pubkey,
|
|
created_at,
|
|
hexPubkeys = []
|
|
} = params || {};
|
|
const schema = getNostrEventSchema(resolveAdminNoteType(listKey));
|
|
if (!schema) {
|
|
devLogger.warn(
|
|
`[nostrEventSchemas] Unknown admin list key: ${listKey}`,
|
|
);
|
|
return {
|
|
kind: 30000,
|
|
pubkey,
|
|
created_at,
|
|
tags: [],
|
|
content: "",
|
|
};
|
|
}
|
|
|
|
const tags = [];
|
|
if (schema?.identifierTag?.name && schema?.identifierTag?.value) {
|
|
tags.push([schema.identifierTag.name, schema.identifierTag.value]);
|
|
}
|
|
const participantTagName = schema?.participantTagName || "p";
|
|
hexPubkeys.forEach((hex) => {
|
|
if (typeof hex === "string" && hex.trim()) {
|
|
tags.push([participantTagName, hex.trim()]);
|
|
}
|
|
});
|
|
appendSchemaTags(tags, schema);
|
|
|
|
const event = {
|
|
kind: schema?.kind ?? 30000,
|
|
pubkey,
|
|
created_at,
|
|
tags,
|
|
content: "",
|
|
};
|
|
|
|
if (isDevMode) {
|
|
const resolvedType = resolveAdminNoteType(listKey);
|
|
if (resolvedType) {
|
|
validateEventAgainstSchema(resolvedType, event);
|
|
}
|
|
}
|
|
|
|
return event;
|
|
}
|
|
|
|
function hasTag(tags, tagName, tagValue = null) {
|
|
if (!Array.isArray(tags)) return false;
|
|
return tags.some(
|
|
(tag) =>
|
|
Array.isArray(tag) &&
|
|
tag[0] === tagName &&
|
|
(tagValue === null || tag[1] === tagValue)
|
|
);
|
|
}
|
|
|
|
export function validateEventAgainstSchema(type, event) {
|
|
if (!isDevMode || !event) return;
|
|
|
|
const schema = getNostrEventSchema(type);
|
|
if (!schema) return;
|
|
|
|
if (event.kind !== schema.kind) {
|
|
devLogger.warn(
|
|
`[schema] Kind mismatch for ${type}: expected ${schema.kind}, got ${event.kind}`,
|
|
);
|
|
}
|
|
|
|
if (schema.topicTag) {
|
|
if (!hasTag(event.tags, schema.topicTag.name, schema.topicTag.value)) {
|
|
devLogger.warn(
|
|
`[schema] Missing topic tag for ${type}: ${schema.topicTag.name}=${schema.topicTag.value}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
if (schema.identifierTag) {
|
|
const expectedValue = schema.identifierTag.value;
|
|
if (!hasTag(event.tags, schema.identifierTag.name, expectedValue)) {
|
|
devLogger.warn(
|
|
`[schema] Missing identifier tag for ${type}: ${schema.identifierTag.name}${
|
|
expectedValue ? "=" + expectedValue : ""
|
|
}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
if (schema.appendTags) {
|
|
schema.appendTags.forEach((appendTag) => {
|
|
if (Array.isArray(appendTag) && appendTag.length >= 2) {
|
|
const tagName = appendTag[0];
|
|
const tagValue = appendTag[1];
|
|
if (!hasTag(event.tags, tagName, tagValue)) {
|
|
devLogger.warn(
|
|
`[schema] Missing append tag for ${type}: ${tagName}=${tagValue}`,
|
|
);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
if (schema.content) {
|
|
if (schema.content.format === "json") {
|
|
try {
|
|
JSON.parse(event.content);
|
|
} catch (e) {
|
|
devLogger.warn(`[schema] Content is not valid JSON for ${type}`);
|
|
}
|
|
} else if (schema.content.format === "empty") {
|
|
if (event.content !== "") {
|
|
devLogger.warn(`[schema] Content should be empty for ${type}`);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (typeof window !== "undefined") {
|
|
window.bitvidNostrEvents = {
|
|
NOTE_TYPES,
|
|
getSchema: getNostrEventSchema,
|
|
getAllSchemas: getAllNostrEventSchemas,
|
|
setOverrides: setNostrEventSchemaOverrides,
|
|
validateEvent: validateEventAgainstSchema,
|
|
};
|
|
}
|