mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 16:02:26 +00:00
Extracted parameter normalization, context resolution, and tag building into dedicated helper functions (`normalizeCommentInputs`, `resolveCommentContext`, `buildCommentTags`) to improve readability and maintainability of `buildCommentEvent`. This simplifies the main function logic and makes it easier to test individual components in the future if exported. No functional changes intended. Verified with existing unit and integration tests. Co-authored-by: PR0M3TH3AN <[email protected]>
2761 lines
72 KiB
JavaScript
2761 lines
72 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_VERSION_TAG_VALUE,
|
|
} from "./config.js";
|
|
import {
|
|
buildStoragePointerValue,
|
|
deriveStoragePointerFromUrl,
|
|
} from "./utils/storagePointer.js";
|
|
|
|
export const NOTE_TYPES = Object.freeze({
|
|
VIDEO_POST: "videoPost",
|
|
VIDEO_MIRROR: "videoMirror",
|
|
NIP71_VIDEO: "nip71Video",
|
|
NIP71_SHORT_VIDEO: "nip71ShortVideo",
|
|
REPOST: "repost",
|
|
GENERIC_REPOST: "genericRepost",
|
|
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",
|
|
SUBSCRIPTION_BACKUP: "subscriptionBackup",
|
|
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",
|
|
HTTP_AUTH: "httpAuth",
|
|
REPORT: "report",
|
|
});
|
|
|
|
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 "";
|
|
}
|
|
if (typeof value === "object") {
|
|
try {
|
|
return JSON.stringify(value);
|
|
} catch (e) {
|
|
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" },
|
|
requiredTagNames: ["s"],
|
|
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: "infoHash", type: "string", required: false },
|
|
{ key: "fileSha256", type: "string", required: false },
|
|
{ key: "originalFileSha256", type: "string", 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: {
|
|
description: "Content field contains the JSON-serialized event being reposted (or empty if unavailable).",
|
|
},
|
|
},
|
|
[NOTE_TYPES.GENERIC_REPOST]: {
|
|
type: NOTE_TYPES.GENERIC_REPOST,
|
|
label: "Generic repost",
|
|
kind: 16,
|
|
appendTags: DEFAULT_APPEND_TAGS,
|
|
content: {
|
|
description: "Content field contains the JSON-serialized event being reposted (or empty if unavailable).",
|
|
},
|
|
},
|
|
[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: [["encrypted", "nip44_v2"]],
|
|
content: {
|
|
format: "encrypted-tag-list",
|
|
description:
|
|
"NIP-04/NIP-44 encrypted JSON array of NIP-51 tag tuples (e.g., [['p', <hex>], …]).",
|
|
},
|
|
},
|
|
[NOTE_TYPES.SUBSCRIPTION_BACKUP]: {
|
|
type: NOTE_TYPES.SUBSCRIPTION_BACKUP,
|
|
label: "Subscription list backup",
|
|
kind: 30000,
|
|
identifierTag: {
|
|
name: "d",
|
|
},
|
|
appendTags: [["encrypted", "nip44_v2"]],
|
|
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 (legacy)",
|
|
kind: 10000,
|
|
identifierTag: {
|
|
name: "d",
|
|
value: BLOCK_LIST_IDENTIFIER,
|
|
},
|
|
appendTags: DEFAULT_APPEND_TAGS,
|
|
content: {
|
|
format: "encrypted-tag-list",
|
|
description:
|
|
"Legacy NIP-51 block list using custom d tag. Migrated to standard Mute List (kind 10000).",
|
|
},
|
|
},
|
|
[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: "Standard NIP-51 Mute List. Public mutes in 'p' tags, private blocks in encrypted content.",
|
|
},
|
|
},
|
|
[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.",
|
|
},
|
|
},
|
|
[NOTE_TYPES.HTTP_AUTH]: {
|
|
type: NOTE_TYPES.HTTP_AUTH,
|
|
label: "HTTP Auth",
|
|
kind: 27235,
|
|
domainTagName: "u",
|
|
methodTagName: "method",
|
|
payloadTagName: "payload",
|
|
appendTags: DEFAULT_APPEND_TAGS,
|
|
content: {
|
|
format: "text",
|
|
description: "Optional content (NIP-98 recommends empty, but some implementations use it).",
|
|
},
|
|
},
|
|
[NOTE_TYPES.REPORT]: {
|
|
type: NOTE_TYPES.REPORT,
|
|
label: "Report",
|
|
kind: 1984,
|
|
appendTags: DEFAULT_APPEND_TAGS,
|
|
content: {
|
|
format: "text",
|
|
description: "Report reason.",
|
|
},
|
|
},
|
|
};
|
|
|
|
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") {
|
|
try {
|
|
JSON.stringify(overrides);
|
|
schemaOverrides = overrides;
|
|
} catch (error) {
|
|
devLogger.warn(
|
|
"[nostrEventSchemas] Invalid overrides provided (circular structure?):",
|
|
error
|
|
);
|
|
schemaOverrides = {};
|
|
}
|
|
} else {
|
|
schemaOverrides = {};
|
|
}
|
|
|
|
if (typeof window !== "undefined") {
|
|
window.bitvidNostrEventOverrides = schemaOverrides;
|
|
}
|
|
}
|
|
|
|
export function getNostrEventSchema(type) {
|
|
if (typeof type !== "string") {
|
|
return null;
|
|
}
|
|
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()));
|
|
}
|
|
|
|
const hasSTag = tags.some((t) => t[0] === "s");
|
|
if (!hasSTag) {
|
|
let contentObj = content;
|
|
if (typeof content === "string") {
|
|
try {
|
|
contentObj = JSON.parse(content);
|
|
} catch (e) {
|
|
contentObj = {};
|
|
}
|
|
}
|
|
contentObj = contentObj || {};
|
|
|
|
let pointer = "";
|
|
if (contentObj.infoHash) {
|
|
pointer = buildStoragePointerValue({
|
|
provider: "btih",
|
|
prefix: contentObj.infoHash,
|
|
});
|
|
} else if (contentObj.url) {
|
|
pointer = deriveStoragePointerFromUrl(contentObj.url);
|
|
}
|
|
|
|
if (!pointer && (dTagValue || contentObj.videoRootId)) {
|
|
const prefix = dTagValue || contentObj.videoRootId;
|
|
pointer = buildStoragePointerValue({ provider: "nostr", prefix });
|
|
}
|
|
|
|
if (pointer) {
|
|
tags.push(["s", pointer]);
|
|
}
|
|
}
|
|
|
|
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 buildHttpAuthEvent(params) {
|
|
const {
|
|
pubkey,
|
|
created_at,
|
|
url,
|
|
method,
|
|
payload,
|
|
content,
|
|
additionalTags = [],
|
|
} = params || {};
|
|
const schema = getNostrEventSchema(NOTE_TYPES.HTTP_AUTH);
|
|
const tags = [];
|
|
|
|
const domainTagName = schema?.domainTagName || "u";
|
|
if (url) {
|
|
tags.push([domainTagName, url]);
|
|
}
|
|
|
|
const methodTagName = schema?.methodTagName || "method";
|
|
if (method) {
|
|
tags.push([methodTagName, method]);
|
|
}
|
|
|
|
const payloadTagName = schema?.payloadTagName || "payload";
|
|
if (payload) {
|
|
tags.push([payloadTagName, payload]);
|
|
}
|
|
|
|
appendSchemaTags(tags, schema);
|
|
const sanitizedAdditionalTags = sanitizeAdditionalTags(additionalTags);
|
|
if (sanitizedAdditionalTags.length) {
|
|
tags.push(...sanitizedAdditionalTags.map((tag) => tag.slice()));
|
|
}
|
|
|
|
const event = {
|
|
kind: schema?.kind ?? 27235,
|
|
pubkey,
|
|
created_at,
|
|
tags,
|
|
content: typeof content === "string" ? content : "",
|
|
};
|
|
|
|
if (isDevMode) {
|
|
validateEventAgainstSchema(NOTE_TYPES.HTTP_AUTH, event);
|
|
}
|
|
|
|
return event;
|
|
}
|
|
|
|
export function buildReportEvent(params) {
|
|
const {
|
|
pubkey,
|
|
created_at,
|
|
eventId,
|
|
userId,
|
|
reportType,
|
|
relayHint,
|
|
content,
|
|
additionalTags = [],
|
|
} = params || {};
|
|
const schema = getNostrEventSchema(NOTE_TYPES.REPORT);
|
|
const tags = [];
|
|
|
|
const normalizedReportType = typeof reportType === "string" ? reportType.trim() : "";
|
|
const normalizedRelayHint = typeof relayHint === "string" ? relayHint.trim() : "";
|
|
|
|
if (eventId) {
|
|
const eTag = ["e", eventId];
|
|
if (normalizedReportType || normalizedRelayHint) {
|
|
eTag.push(normalizedReportType);
|
|
}
|
|
if (normalizedRelayHint) {
|
|
eTag.push(normalizedRelayHint);
|
|
}
|
|
tags.push(eTag);
|
|
}
|
|
|
|
if (userId) {
|
|
const pTag = ["p", userId];
|
|
if (normalizedReportType) {
|
|
pTag.push(normalizedReportType);
|
|
}
|
|
tags.push(pTag);
|
|
}
|
|
|
|
// Some implementations use 't' tag for report type too?
|
|
// ModerationService logic:
|
|
// tags.push(["t", normalizedType]);
|
|
// So we support it.
|
|
if (normalizedReportType) {
|
|
// Check if we should add it as 't' tag or if it's already in e/p
|
|
// ModerationService adds it explicitly
|
|
const hasTTag = tags.some(t => t[0] === "t" && t[1] === normalizedReportType);
|
|
if (!hasTTag) {
|
|
// ModerationService does: ["t", normalizedType]
|
|
// But let's check what NIP-56 says. It relies on e/p markers.
|
|
// But we should match what ModerationService expects/does to be safe.
|
|
// It adds ["t", normalizedType]
|
|
tags.push(["t", normalizedReportType]);
|
|
}
|
|
}
|
|
|
|
appendSchemaTags(tags, schema);
|
|
const sanitizedAdditionalTags = sanitizeAdditionalTags(additionalTags);
|
|
if (sanitizedAdditionalTags.length) {
|
|
tags.push(...sanitizedAdditionalTags.map((tag) => tag.slice()));
|
|
}
|
|
|
|
const event = {
|
|
kind: schema?.kind ?? 1984,
|
|
pubkey,
|
|
created_at,
|
|
tags,
|
|
content: typeof content === "string" ? content : "",
|
|
};
|
|
|
|
if (isDevMode) {
|
|
validateEventAgainstSchema(NOTE_TYPES.REPORT, 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) {
|
|
const type = event.kind === 16 ? NOTE_TYPES.GENERIC_REPOST : NOTE_TYPES.REPOST;
|
|
validateEventAgainstSchema(type, 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()));
|
|
}
|
|
|
|
const resolvedDedupeTag =
|
|
dedupeTag ||
|
|
(schema?.identifierTag?.name
|
|
? Math.random().toString(36).slice(2) + Math.random().toString(36).slice(2)
|
|
: "");
|
|
|
|
if (resolvedDedupeTag && schema?.identifierTag?.name) {
|
|
const identifierName = schema.identifierTag.name;
|
|
const hasDedupe = tags.some(
|
|
(tag) => tag[0] === identifierName && tag[1] === resolvedDedupeTag
|
|
);
|
|
if (!hasDedupe) {
|
|
tags.push([identifierName, resolvedDedupeTag]);
|
|
}
|
|
}
|
|
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;
|
|
}
|
|
|
|
function normalizeCommentInputs(params) {
|
|
const normalizeString = (value) =>
|
|
typeof value === "string" ? value.trim() : "";
|
|
|
|
return {
|
|
normalizedVideoEventId: normalizeString(params.videoEventId),
|
|
normalizedVideoEventRelay: normalizeString(params.videoEventRelay),
|
|
normalizedVideoDefinitionAddress: normalizeString(params.videoDefinitionAddress),
|
|
normalizedVideoDefinitionRelay: normalizeString(params.videoDefinitionRelay),
|
|
normalizedRootIdentifier: normalizeString(params.rootIdentifier),
|
|
normalizedRootIdentifierRelay: normalizeString(params.rootIdentifierRelay),
|
|
normalizedParentCommentId: normalizeString(params.parentCommentId),
|
|
normalizedParentCommentRelay: normalizeString(params.parentCommentRelay),
|
|
normalizedThreadParticipantPubkey: normalizeString(params.threadParticipantPubkey),
|
|
normalizedThreadParticipantRelay: normalizeString(params.threadParticipantRelay),
|
|
normalizedParentIdentifier: normalizeString(params.parentIdentifier),
|
|
normalizedParentIdentifierRelay: normalizeString(params.parentIdentifierRelay),
|
|
resolvedRootKind: normalizeString(params.rootKind),
|
|
resolvedRootAuthorPubkey: normalizeString(params.rootAuthorPubkey),
|
|
resolvedRootAuthorRelay: normalizeString(params.rootAuthorRelay),
|
|
resolvedParentAuthorPubkey: normalizeString(params.parentAuthorPubkey),
|
|
resolvedParentAuthorRelay: normalizeString(params.parentAuthorRelay),
|
|
resolvedParentKind: normalizeString(params.parentKind),
|
|
};
|
|
}
|
|
|
|
function resolveCommentContext(inputs, schema) {
|
|
let {
|
|
resolvedRootKind,
|
|
resolvedRootAuthorPubkey,
|
|
resolvedRootAuthorRelay,
|
|
resolvedParentAuthorPubkey,
|
|
resolvedParentAuthorRelay,
|
|
resolvedParentKind,
|
|
normalizedVideoDefinitionAddress,
|
|
normalizedVideoDefinitionRelay,
|
|
normalizedThreadParticipantPubkey,
|
|
normalizedThreadParticipantRelay,
|
|
normalizedParentCommentId,
|
|
} = inputs;
|
|
|
|
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;
|
|
}
|
|
}
|
|
|
|
if (!resolvedParentAuthorPubkey) {
|
|
resolvedParentAuthorPubkey = normalizedThreadParticipantPubkey;
|
|
}
|
|
|
|
if (!resolvedParentAuthorRelay) {
|
|
resolvedParentAuthorRelay = normalizedThreadParticipantRelay;
|
|
}
|
|
|
|
if (!resolvedRootAuthorPubkey && !normalizedParentCommentId) {
|
|
resolvedRootAuthorPubkey = resolvedParentAuthorPubkey;
|
|
}
|
|
if (!resolvedRootAuthorRelay && !normalizedParentCommentId) {
|
|
resolvedRootAuthorRelay = resolvedParentAuthorRelay;
|
|
}
|
|
|
|
if (!resolvedParentAuthorPubkey && !normalizedParentCommentId) {
|
|
resolvedParentAuthorPubkey = resolvedRootAuthorPubkey;
|
|
}
|
|
if (!resolvedParentAuthorRelay && !normalizedParentCommentId) {
|
|
resolvedParentAuthorRelay = resolvedRootAuthorRelay;
|
|
}
|
|
|
|
if (!resolvedParentKind) {
|
|
if (normalizedParentCommentId) {
|
|
resolvedParentKind = String(schema?.kind ?? 1111);
|
|
} else if (resolvedRootKind) {
|
|
resolvedParentKind = resolvedRootKind;
|
|
}
|
|
}
|
|
|
|
if (!resolvedRootKind) {
|
|
resolvedRootKind = resolvedParentKind;
|
|
}
|
|
|
|
return {
|
|
resolvedRootKind,
|
|
resolvedRootAuthorPubkey,
|
|
resolvedRootAuthorRelay,
|
|
resolvedParentAuthorPubkey,
|
|
resolvedParentAuthorRelay,
|
|
resolvedParentKind,
|
|
};
|
|
}
|
|
|
|
function buildCommentTags(schema, inputs, context) {
|
|
const {
|
|
normalizedVideoEventId,
|
|
normalizedVideoEventRelay,
|
|
normalizedVideoDefinitionAddress,
|
|
normalizedVideoDefinitionRelay,
|
|
normalizedRootIdentifier,
|
|
normalizedRootIdentifierRelay,
|
|
normalizedParentCommentId,
|
|
normalizedParentCommentRelay,
|
|
normalizedParentIdentifier,
|
|
normalizedParentIdentifierRelay,
|
|
} = inputs;
|
|
|
|
const {
|
|
resolvedRootKind,
|
|
resolvedRootAuthorPubkey,
|
|
resolvedRootAuthorRelay,
|
|
resolvedParentAuthorPubkey,
|
|
resolvedParentAuthorRelay,
|
|
resolvedParentKind,
|
|
} = context;
|
|
|
|
const tags = [];
|
|
|
|
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;
|
|
}
|
|
}
|
|
}
|
|
|
|
return tags;
|
|
}
|
|
|
|
export function buildCommentEvent(params) {
|
|
const {
|
|
pubkey,
|
|
created_at,
|
|
additionalTags = [],
|
|
content = "",
|
|
} = params || {};
|
|
const schema = getNostrEventSchema(NOTE_TYPES.VIDEO_COMMENT);
|
|
|
|
const inputs = normalizeCommentInputs(params || {});
|
|
const context = resolveCommentContext(inputs, schema);
|
|
const tags = buildCommentTags(schema, inputs, context);
|
|
|
|
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]);
|
|
}
|
|
appendSchemaTags(tags, schema);
|
|
const normalizedEncryption =
|
|
typeof encryption === "string" ? encryption.trim() : "";
|
|
if (normalizedEncryption) {
|
|
let updated = false;
|
|
for (const tag of tags) {
|
|
if (Array.isArray(tag) && tag[0] === "encrypted") {
|
|
tag[1] = normalizedEncryption;
|
|
updated = true;
|
|
break;
|
|
}
|
|
}
|
|
if (!updated) {
|
|
tags.push(["encrypted", normalizedEncryption]);
|
|
}
|
|
}
|
|
|
|
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: ${safeStringify(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";
|
|
if (Array.isArray(hexPubkeys)) {
|
|
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)
|
|
);
|
|
}
|
|
|
|
function hasTagName(tags, tagName) {
|
|
if (!Array.isArray(tags)) return false;
|
|
return tags.some((tag) => {
|
|
if (!Array.isArray(tag) || tag[0] !== tagName) {
|
|
return false;
|
|
}
|
|
if (tag.length < 2 || typeof tag[1] !== "string") {
|
|
return false;
|
|
}
|
|
return tag[1].trim().length > 0;
|
|
});
|
|
}
|
|
|
|
export function validateEventStructure(type, event) {
|
|
const schema = getNostrEventSchema(type);
|
|
const errors = [];
|
|
|
|
if (!event || typeof event !== "object") {
|
|
return { valid: false, errors: ["Event is null or not an object"] };
|
|
}
|
|
|
|
if (!schema) {
|
|
return { valid: false, errors: [`Unknown note type: ${type}`] };
|
|
}
|
|
|
|
if (event.kind !== schema.kind) {
|
|
errors.push(
|
|
`Kind mismatch for ${type}: expected ${schema.kind}, got ${event.kind}`
|
|
);
|
|
}
|
|
|
|
if (schema.topicTag) {
|
|
if (!hasTag(event.tags, schema.topicTag.name, schema.topicTag.value)) {
|
|
errors.push(
|
|
`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)) {
|
|
errors.push(
|
|
`Missing identifier tag for ${type}: ${schema.identifierTag.name}${
|
|
expectedValue ? "=" + expectedValue : ""
|
|
}`
|
|
);
|
|
}
|
|
}
|
|
|
|
if (Array.isArray(schema.requiredTagNames)) {
|
|
schema.requiredTagNames.forEach((tagName) => {
|
|
if (!hasTagName(event.tags, tagName)) {
|
|
errors.push(`Missing required tag for ${type}: ${tagName}`);
|
|
}
|
|
});
|
|
}
|
|
|
|
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)) {
|
|
errors.push(
|
|
`Missing append tag for ${type}: ${tagName}=${tagValue}`
|
|
);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
if (schema.content) {
|
|
if (schema.content.format === "json") {
|
|
let parsed = null;
|
|
try {
|
|
parsed = JSON.parse(event.content);
|
|
} catch (e) {
|
|
errors.push(`Content is not valid JSON for ${type}`);
|
|
}
|
|
|
|
if (parsed && schema.content.fields) {
|
|
schema.content.fields.forEach((field) => {
|
|
const value = parsed[field.key];
|
|
if (field.required && (value === undefined || value === null)) {
|
|
errors.push(`Missing required field in JSON content: ${field.key}`);
|
|
}
|
|
if (
|
|
value !== undefined &&
|
|
value !== null &&
|
|
field.type &&
|
|
typeof value !== field.type
|
|
) {
|
|
errors.push(
|
|
`Field '${field.key}' should be of type ${field.type}, got ${typeof value}`
|
|
);
|
|
}
|
|
});
|
|
}
|
|
} else if (schema.content.format === "empty") {
|
|
if (event.content !== "") {
|
|
errors.push(`Content should be empty for ${type}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
return { valid: errors.length === 0, errors };
|
|
}
|
|
|
|
export function validateEventAgainstSchema(type, event) {
|
|
if (!isDevMode || !event) return;
|
|
|
|
const { valid, errors } = validateEventStructure(type, event);
|
|
if (!valid) {
|
|
errors.forEach((msg) => devLogger.warn(`[schema] ${msg}`));
|
|
}
|
|
}
|
|
|
|
if (typeof window !== "undefined") {
|
|
window.bitvidNostrEvents = {
|
|
NOTE_TYPES,
|
|
getSchema: getNostrEventSchema,
|
|
getAllSchemas: getAllNostrEventSchemas,
|
|
setOverrides: setNostrEventSchemaOverrides,
|
|
validateEvent: validateEventAgainstSchema,
|
|
validateEventStructure,
|
|
};
|
|
}
|