跳至主要內容

NIP-72: 審核社群

Nostr 審核社群定義,支援版主管理和貼文審核機制

概述

NIP-72 定義了在 Nostr 上建立審核社群(Moderated Communities)的標準。 與 NIP-29 的中繼器群組不同,NIP-72 社群的審核是在客戶端層面進行, 允許任何人發布貼文,但只有經過版主批准的內容才會被客戶端顯示。

事件類型

Kind 名稱 說明
34550 社群定義 定義社群資訊、版主和中繼器
1111 社群貼文 發布到社群的文字內容(NIP-22)
4550 審核批准 版主對貼文的批准事件

社群定義 (Kind 34550)

社群定義是一個可替換事件,包含社群的基本資訊、版主列表和中繼器設定。

{
  "kind": 34550,
  "tags": [
    ["d", "nostr-dev"],
    ["name", "Nostr 開發者社群"],
    ["description", "討論 Nostr 協議開發的社群"],
    ["image", "https://example.com/community-logo.png"],
    ["p", "<moderator-pubkey-1>", "wss://relay.example.com", "moderator"],
    ["p", "<moderator-pubkey-2>", "wss://relay.example.com", "moderator"],
    ["relay", "wss://community-relay.example.com", "author"],
    ["relay", "wss://community-relay.example.com", "requests"],
    ["relay", "wss://community-relay.example.com", "approvals"]
  ],
  "content": ""
}

社群標籤

標籤 說明 必要性
d 社群唯一識別符 必要
name 社群顯示名稱(優先於 d 標籤) 建議
description 社群描述 建議
image 社群圖片 URL 選填
p 版主公鑰(帶 "moderator" 角色) 建議
relay 指定用途的中繼器 選填

中繼器角色

  • author:社群創建者發布定義的中繼器
  • requests:用戶提交貼文請求的中繼器
  • approvals:版主發布批准事件的中繼器

社群貼文 (Kind 1111)

使用 NIP-22 格式的 kind 1111 事件發布社群貼文。貼文必須包含 a 標籤指向目標社群。

{
  "kind": 1111,
  "content": "這是我在社群發布的第一篇貼文!",
  "tags": [
    ["a", "34550:<community-creator-pubkey>:nostr-dev", "wss://relay.example.com"],
    ["K", "34550"],
    ["k", "1111"]
  ]
}

向後相容:舊版使用 kind 1 事件發布社群貼文,但新實作應使用 kind 1111。

審核批准 (Kind 4550)

版主透過發布 kind 4550 事件來批准貼文。任何人都可以發布批准事件表達對貼文的認可, 但客戶端通常只會顯示由社群定義中指定的版主所批准的內容。

{
  "kind": 4550,
  "content": "{\"id\":\"...\",\"pubkey\":\"...\",\"content\":\"原始貼文內容...\",\"kind\":1111,...}",
  "tags": [
    ["a", "34550:<community-creator-pubkey>:nostr-dev", "wss://relay.example.com"],
    ["e", "<post-event-id>", "wss://relay.example.com"],
    ["p", "<post-author-pubkey>"],
    ["k", "1111"]
  ]
}

批准事件標籤

標籤 說明
a 指向社群定義(格式:34550:<pubkey>:<d-tag>
e 被批准的貼文事件 ID
p 貼文作者的公鑰(用於通知)
k 原始貼文的 kind

content 欄位

批准事件的 content 必須包含被批准貼文的完整 JSON 字串化內容。 這允許客戶端在無法取得原始貼文時仍能顯示內容。

審核策略

對於可替換事件(如長文 kind 30023),版主可以選擇不同的批准策略:

1. 逐版本批准 (Per-version)

只使用 e 標籤。每次作者更新內容都需要重新批准。

{
  "tags": [
    ["a", "34550:<community-pubkey>:community-id"],
    ["e", "<specific-version-id>"],
    ["p", "<author-pubkey>"],
    ["k", "30023"]
  ]
}

2. 全面授權 (Blanket authorization)

使用 a 標籤指向貼文。作者可以更新內容而無需額外批准。

{
  "tags": [
    ["a", "34550:<community-pubkey>:community-id"],
    ["a", "30023:<author-pubkey>:article-id"],
    ["p", "<author-pubkey>"],
    ["k", "30023"]
  ]
}

3. 雙重標記

同時使用 ea 標籤,顯示原始批准版本和最新版本。

跨社群轉發

社群支援使用 NIP-18 轉發(kind 6 或 kind 16)進行跨社群分享:

{
  "kind": 6,
  "content": "{\"id\":\"...\",\"content\":\"原始貼文...\",\"kind\":1,...}",
  "tags": [
    ["a", "34550:<target-community-pubkey>:target-community"],
    ["e", "<original-post-id>"],
    ["p", "<original-author-pubkey>"]
  ]
}

TypeScript 實作

建立社群

import { finalizeEvent, generateSecretKey, getPublicKey } from 'nostr-tools';

interface CommunityConfig {
  identifier: string;
  name: string;
  description?: string;
  image?: string;
  moderators: Array<{ pubkey: string; relay?: string }>;
  relays?: {
    author?: string;
    requests?: string;
    approvals?: string;
  };
  rules?: string[];
}

function createCommunity(config: CommunityConfig, secretKey: Uint8Array) {
  const tags: string[][] = [
    ['d', config.identifier],
    ['name', config.name],
  ];

  if (config.description) {
    tags.push(['description', config.description]);
  }

  if (config.image) {
    tags.push(['image', config.image]);
  }

  // 添加版主
  config.moderators.forEach((mod) => {
    const pTag = ['p', mod.pubkey];
    if (mod.relay) pTag.push(mod.relay);
    pTag.push('moderator');
    tags.push(pTag);
  });

  // 添加中繼器設定
  if (config.relays) {
    if (config.relays.author) {
      tags.push(['relay', config.relays.author, 'author']);
    }
    if (config.relays.requests) {
      tags.push(['relay', config.relays.requests, 'requests']);
    }
    if (config.relays.approvals) {
      tags.push(['relay', config.relays.approvals, 'approvals']);
    }
  }

  // 添加規則
  if (config.rules) {
    config.rules.forEach((rule, index) => {
      tags.push(['rule', rule, (index + 1).toString()]);
    });
  }

  const event = {
    kind: 34550,
    content: '',
    tags,
    created_at: Math.floor(Date.now() / 1000),
  };

  return finalizeEvent(event, secretKey);
}

// 使用範例
const sk = generateSecretKey();
const pk = getPublicKey(sk);

const community = createCommunity(
  {
    identifier: 'nostr-taiwan',
    name: 'Nostr 台灣社群',
    description: '台灣 Nostr 愛好者的討論空間',
    image: 'https://example.com/tw-nostr.png',
    moderators: [
      { pubkey: pk, relay: 'wss://relay.damus.io' },
    ],
    relays: {
      author: 'wss://relay.damus.io',
      requests: 'wss://relay.damus.io',
      approvals: 'wss://relay.damus.io',
    },
    rules: [
      '保持友善和尊重',
      '禁止垃圾訊息和廣告',
      '討論需與 Nostr 相關',
    ],
  },
  sk
);

console.log(community);

發布社群貼文

interface CommunityReference {
  creatorPubkey: string;
  identifier: string;
  relay?: string;
}

function createCommunityPost(
  content: string,
  community: CommunityReference,
  secretKey: Uint8Array,
  options?: {
    replyTo?: { eventId: string; authorPubkey: string };
    hashtags?: string[];
  }
) {
  const communityTag = `34550:${community.creatorPubkey}:${community.identifier}`;

  const tags: string[][] = [
    ['a', communityTag, community.relay || ''],
    ['K', '34550'],
    ['k', '1111'],
  ];

  // 添加回覆標籤
  if (options?.replyTo) {
    tags.push(['e', options.replyTo.eventId, '', 'reply']);
    tags.push(['p', options.replyTo.authorPubkey]);
  }

  // 添加主題標籤
  if (options?.hashtags) {
    options.hashtags.forEach((tag) => {
      tags.push(['t', tag]);
    });
  }

  const event = {
    kind: 1111,
    content,
    tags,
    created_at: Math.floor(Date.now() / 1000),
  };

  return finalizeEvent(event, secretKey);
}

// 使用範例
const post = createCommunityPost(
  '大家好!這是我在社群的第一篇貼文。',
  {
    creatorPubkey: '<community-creator-pubkey>',
    identifier: 'nostr-taiwan',
    relay: 'wss://relay.damus.io',
  },
  sk,
  {
    hashtags: ['nostr', '台灣'],
  }
);

批准貼文

function approvePost(
  post: any,
  community: CommunityReference,
  moderatorSecretKey: Uint8Array,
  options?: {
    blanketAuthorization?: boolean;
  }
) {
  const communityTag = `34550:${community.creatorPubkey}:${community.identifier}`;

  const tags: string[][] = [
    ['a', communityTag, community.relay || ''],
    ['p', post.pubkey],
    ['k', post.kind.toString()],
  ];

  // 添加貼文引用
  if (options?.blanketAuthorization && post.kind >= 30000 && post.kind < 40000) {
    // 可替換事件的全面授權
    const dTag = post.tags.find((t: string[]) => t[0] === 'd')?.[1];
    if (dTag) {
      tags.push(['a', `${post.kind}:${post.pubkey}:${dTag}`]);
    }
  }

  // 總是添加特定版本引用
  tags.push(['e', post.id, community.relay || '']);

  const event = {
    kind: 4550,
    content: JSON.stringify(post),
    tags,
    created_at: Math.floor(Date.now() / 1000),
  };

  return finalizeEvent(event, moderatorSecretKey);
}

// 使用範例
const approval = approvePost(
  post,
  {
    creatorPubkey: '<community-creator-pubkey>',
    identifier: 'nostr-taiwan',
    relay: 'wss://relay.damus.io',
  },
  sk
);

查詢社群內容

import { SimplePool } from 'nostr-tools';

const pool = new SimplePool();
const relays = ['wss://relay.damus.io', 'wss://nos.lol'];

// 查詢社群定義
async function getCommunity(creatorPubkey: string, identifier: string) {
  const events = await pool.querySync(relays, {
    kinds: [34550],
    authors: [creatorPubkey],
    '#d': [identifier],
  });

  return events[0];
}

// 查詢社群貼文(待審核)
async function getPendingPosts(communityTag: string) {
  const posts = await pool.querySync(relays, {
    kinds: [1111],
    '#a': [communityTag],
  });

  return posts;
}

// 查詢已批准的貼文
async function getApprovedPosts(
  communityTag: string,
  moderatorPubkeys: string[]
) {
  const approvals = await pool.querySync(relays, {
    kinds: [4550],
    authors: moderatorPubkeys,
    '#a': [communityTag],
  });

  // 從批准事件中提取貼文
  const posts = approvals.map((approval) => {
    try {
      return JSON.parse(approval.content);
    } catch {
      return null;
    }
  }).filter(Boolean);

  return posts;
}

// 使用範例
const communityTag = '34550:<creator-pubkey>:nostr-taiwan';
const community = await getCommunity('<creator-pubkey>', 'nostr-taiwan');

if (community) {
  const moderators = community.tags
    .filter((t: string[]) => t[0] === 'p' && t[3] === 'moderator')
    .map((t: string[]) => t[1]);

  const approvedPosts = await getApprovedPosts(communityTag, moderators);
  console.log(`已批准貼文: ${approvedPosts.length}`);
}

審核流程

  1. 用戶發布貼文:使用 kind 1111 發布到社群
  2. 版主審核:版主查看待審核貼文
  3. 發布批准:版主發布 kind 4550 批准事件
  4. 客戶端顯示:客戶端只顯示已批准的貼文

注意:審核是在客戶端層面進行的,中繼器不會過濾未批准的貼文。 這意味著所有貼文都會被儲存,只是客戶端選擇只顯示已批准的內容。

使用場景

主題討論區

  • 技術討論社群
  • 興趣愛好群組
  • 地區社群

內容策展

  • 精選文章集合
  • 新聞聚合
  • 教學資源

組織管理

  • 公司公告板
  • 專案協作空間
  • 會員專區

最佳實踐

  • 設定多位版主:避免單點故障,確保審核效率
  • 明確社群規則:在描述中說明什麼內容會被批准
  • 及時審核:定期處理待審核貼文,保持社群活躍
  • 保存原始內容:批准事件應包含完整的原始貼文
  • 考慮用戶體驗:讓用戶知道貼文正在等待審核

與 NIP-29 比較

特性 NIP-72 審核社群 NIP-29 中繼器群組
審核層級 客戶端 中繼器
貼文儲存 所有貼文都儲存 可拒絕未授權貼文
隱私性 較低(所有貼文可見) 較高(可限制存取)
去中心化 高(不依賴特定中繼器) 中(依賴群組中繼器)
  • NIP-29:中繼器群組 - 另一種群組機制
  • NIP-18:轉發 - 跨社群分享
  • NIP-23:長文內容 - 可發布到社群
  • NIP-32:標籤系統 - 內容分類

參考資源

已複製連結
已複製到剪貼簿