#!/usr/bin/env node class KindAwareContentChecker { constructor() { // Define kinds that should bypass content similarity checks this.standardizedContentKinds = new Set([ 0, // Metadata 3, // Follows 4, // Encrypted Direct Messages 5, // Event Deletion 6, // Repost 7, // Reaction 8, // Badge Award 9, // Chat Message 13, // Seal 14, // Direct Message 16, // Generic Repost 17, // Website Reaction 40, // Channel Creation 41, // Channel Metadata 42, // Channel Message 43, // Channel Hide Message 44, // Channel Mute User 1063, // File Metadata 1984, // Reporting 1985, // Label 1986, // Relay reviews 2003, // Torrent 9734, // Zap Request 9735, // Zap 10000, // Mute list 10001, // Pin list 10002, // Relay List Metadata 10003, // Bookmark list 10004, // Communities list 10005, // Public chats list 10006, // Blocked relays list 10007, // Search relays list 10009, // User groups 10013, // Draft relays 10015, // Interests list 10019, // Nutzap Mint Recommendation 10030, // User emoji list 10050, // Relay list for DMs 10063, // User server list 10096, // File storage server list 13194, // Wallet Info 22242, // Client Authentication 23194, // Wallet Request 23195, // Wallet Response 24133, // Nostr Connect 24242, // Blobs on mediaservers 27235, // HTTP Auth 30000, // Follow sets 30001, // Generic lists 30002, // Relay sets 30003, // Bookmark sets 30004, // Curation sets 30005, // Video sets 30007, // Kind mute sets 30008, // Profile Badges 30009, // Badge Definition 30017, // Create/update stall 30018, // Create/update product 30019, // Marketplace UI/UX 30023, // Long-form Content 30024, // Draft Long-form Content 30078, // Application-specific Data 30311, // Live Event 30315, // User Statuses 30402, // Classified Listing 31234, // Draft Event 31922, // Date-Based Calendar Event 31923, // Time-Based Calendar Event 31924, // Calendar 31925, // Calendar Event RSVP 34235, // Video Event 34550, // Community Definition 37375, // Cashu Wallet Event 38383 // Peer-to-peer Order events ]); // Define kinds that require special content validation rules this.specialContentRules = new Map([ [0, this.validateMetadata.bind(this)], // Metadata [3, this.validateFollowList.bind(this)], // Follow Lists [4, this.validateEncryptedDM.bind(this)], // Encrypted DM [6, this.validateRepost.bind(this)], // Reposts [7, this.validateReaction.bind(this)], // Reactions [14, this.validateDirectMessage.bind(this)], // Direct Message [16, this.validateRepost.bind(this)], // Generic Reposts [30023, this.validateLongForm.bind(this)], // Long-form Content [10002, this.validateRelayList.bind(this)] // Relay Lists ]); } // Main check function checkContent(event, recentEvents) { // If this kind should bypass content checks entirely if (this.standardizedContentKinds.has(event.kind)) { return { isSpam: false, reason: null }; } // If this kind has special validation rules if (this.specialContentRules.has(event.kind)) { return this.specialContentRules.get(event.kind)(event, recentEvents); } // For other kinds, apply standard similarity check return this.checkContentSimilarity(event, recentEvents); } // Specialized validator for follow lists (kind 3) validateFollowList(event) { // Get all p tags const pTags = event.tags.filter(tag => tag[0] === 'p'); if (pTags.length === 0) { return { isSpam: true, reason: 'Invalid follow list: missing p tags' }; } // Content should be empty per NIP-02 if (event.content !== '') { return { isSpam: true, reason: 'Invalid follow list: content must be empty' }; } // Valid key format checking const invalidKeys = pTags.filter(tag => tag.length < 2 || !/^[0-9a-f]{64}$/.test(tag[1]) ); if (invalidKeys.length > 0) { return { isSpam: true, reason: 'Invalid follow list: contains invalid pubkeys' }; } return { isSpam: false, reason: null }; } // Specialized validator for reactions (kind 7) validateReaction(event) { // Valid reaction contents according to NIP-25 const validReactions = new Set(['+', '-', '', '👍', '❤️', '🤙', '⚡']); // Check if it's a standard reaction if (validReactions.has(event.content)) { return { isSpam: false, reason: null }; } // Check if it's a valid emoji reaction if (this.isValidEmojiReaction(event)) { return { isSpam: false, reason: null }; } // Check required tags for reactions if (!this.hasValidReactionTags(event)) { return { isSpam: true, reason: 'Invalid reaction: missing required tags' }; } return { isSpam: false, reason: null }; } // Helper for validating emoji reactions isValidEmojiReaction(event) { // Check for custom emoji format (NIP-30) if (event.content.startsWith(':') && event.content.endsWith(':')) { return event.tags.some(tag => tag[0] === 'emoji' && tag[1] === event.content.slice(1, -1) ); } // Check if it's a single unicode emoji const emojiRegex = /^\p{Emoji}$/u; return emojiRegex.test(event.content.trim()); } hasValidReactionTags(event) { const hasEventTag = event.tags.some(tag => tag[0] === 'e'); const hasPubkeyTag = event.tags.some(tag => tag[0] === 'p'); return hasEventTag && hasPubkeyTag; } // Specialized validator for reposts (kinds 6 and 16) validateRepost(event) { const hasEventTag = event.tags.some(tag => tag[0] === 'e'); if (!hasEventTag) { return { isSpam: true, reason: 'Invalid repost: missing event reference' }; } // For kind 6, content should be empty if (event.kind === 6 && event.content !== '') { return { isSpam: true, reason: 'Invalid repost: kind 6 should have empty content' }; } return { isSpam: false, reason: null }; } // Specialized validator for metadata (kind 0) validateMetadata(event) { try { const metadata = JSON.parse(event.content); if (typeof metadata !== 'object' || metadata === null) { return { isSpam: true, reason: 'Invalid metadata: not an object' }; } return { isSpam: false, reason: null }; } catch (e) { return { isSpam: true, reason: 'Invalid metadata: malformed JSON' }; } } // Standard similarity check for regular content checkContentSimilarity(event, recentEvents) { const similarEvents = recentEvents.filter(e => e.pubkey !== event.pubkey && e.kind === event.kind && this.calculateSimilarity(e.content, event.content) > 0.8 ); if (similarEvents.length > 0) { return { isSpam: true, reason: `Content too similar to ${similarEvents.length} recent events` }; } return { isSpam: false, reason: null }; } // Levenshtein distance for content similarity calculateSimilarity(str1, str2) { if (str1 === str2) return 1.0; const len1 = str1.length; const len2 = str2.length; const maxLen = Math.max(len1, len2); if (maxLen === 0) return 1.0; const distance = this.levenshteinDistance(str1, str2); return (maxLen - distance) / maxLen; } levenshteinDistance(str1, str2) { const m = str1.length; const n = str2.length; const dp = Array.from({ length: m + 1 }, () => Array(n + 1).fill(0)); for (let i = 0; i <= m; i++) dp[i][0] = i; for (let j = 0; j <= n; j++) dp[0][j] = j; for (let i = 1; i <= m; i++) { for (let j = 1; j <= n; j++) { if (str1[i - 1] === str2[j - 1]) { dp[i][j] = dp[i - 1][j - 1]; } else { dp[i][j] = Math.min( dp[i - 1][j - 1], dp[i - 1][j], dp[i][j - 1] ) + 1; } } } return dp[m][n]; } // Other specialized validators... validateEncryptedDM(event) { const hasPubkeyTag = event.tags.some(tag => tag[0] === 'p'); if (!hasPubkeyTag) { return { isSpam: true, reason: 'Invalid encrypted DM: missing pubkey tag' }; } if (!event.content || event.content.length < 100) { return { isSpam: true, reason: 'Invalid encrypted DM: content too short' }; } return { isSpam: false, reason: null }; } validateDirectMessage(event) { const hasPubkeyTag = event.tags.some(tag => tag[0] === 'p'); if (!hasPubkeyTag) { return { isSpam: true, reason: 'Invalid DM: missing pubkey tag' }; } return { isSpam: false, reason: null }; } validateLongForm(event) { try { const hasTitle = event.tags.some(tag => tag[0] === 'title'); const hasPublishedAt = event.tags.some(tag => tag[0] === 'published_at'); if (!hasTitle || !hasPublishedAt) { return { isSpam: true, reason: 'Invalid long-form content: missing required tags' }; } return { isSpam: false, reason: null }; } catch (e) { return { isSpam: true, reason: 'Invalid long-form content: malformed' }; } } validateRelayList(event) { try { const relays = JSON.parse(event.content); if (typeof relays !== 'object' || relays === null) { return { isSpam: true, reason: 'Invalid relay list: not an object' }; } return { isSpam: false, reason: null }; } catch (e) { return { isSpam: true, reason: 'Invalid relay list: malformed JSON' }; } } } module.exports = KindAwareContentChecker;