add kind checker
This commit is contained in:
parent
a126ff0e4f
commit
0908385008
370
kind-aware-content-checker.js
Normal file
370
kind-aware-content-checker.js
Normal file
@ -0,0 +1,370 @@
|
||||
#!/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;
|
Loading…
Reference in New Issue
Block a user