跳至主要內容
入門

NIP-56 檢舉

Nostr 內容檢舉標準,讓用戶可以標記不當內容供客戶端和中繼器參考。

8 分鐘

概述

NIP-56 定義了內容檢舉標準,使用 kind 1984 事件讓用戶可以標記不當內容。 這些檢舉可以被客戶端和中繼器用於內容審核決策,但最終的處理方式 由各平台自行決定。

去中心化審核

Nostr 沒有中央審核機構。檢舉是一種信號,客戶端可以根據用戶的社交圖譜 (如朋友的檢舉)來決定如何處理內容。

事件結構

檢舉使用 kind 1984 事件:

{`{
  "kind": 1984,
  "content": "檢舉原因說明(選填)",
  "tags": [
    ["p", "<被檢舉者公鑰>", "<檢舉類型>"],
    ["e", "<被檢舉事件ID>", "<檢舉類型>"]
  ],
  ...
}`}

Kind 1984

這個 kind 號碼是對喬治·歐威爾小說《1984》的引用, 暗示了審核系統的雙面性。

檢舉類型

類型 說明
nudity 裸露或成人內容
malware 病毒、木馬、間諜軟體等
profanity 髒話或仇恨言論
illegal 違法內容(依司法管轄區)
spam 垃圾訊息
impersonation 冒充他人
other 其他

檢舉用戶

檢舉用戶的整體行為:

{`{
  "kind": 1984,
  "content": "此帳號持續發送垃圾訊息",
  "tags": [
    ["p", "<被檢舉者公鑰>", "spam"]
  ],
  ...
}`}

檢舉特定事件

檢舉特定貼文或事件,需要同時包含 ep 標籤:

{`{
  "kind": 1984,
  "content": "這則貼文包含不當圖片",
  "tags": [
    ["e", "<事件ID>", "nudity"],
    ["p", "<事件作者公鑰>"]
  ],
  ...
}`}

檢舉媒體

檢舉特定的媒體檔案(使用檔案雜湊):

{`{
  "kind": 1984,
  "content": "此圖片包含惡意軟體",
  "tags": [
    ["x", "<檔案SHA-256雜湊>"],
    ["e", "<包含此媒體的事件ID>"],
    ["p", "<發布者公鑰>"],
    ["server", "https://media.example.com"]
  ],
  ...
}`}

實作

建立檢舉

{`import { finalizeEvent } from 'nostr-tools';

type ReportType =
  | 'nudity'
  | 'malware'
  | 'profanity'
  | 'illegal'
  | 'spam'
  | 'impersonation'
  | 'other';

// 檢舉用戶
function reportUser(
  privateKey: Uint8Array,
  userPubkey: string,
  reportType: ReportType,
  reason?: string
) {
  return finalizeEvent({
    kind: 1984,
    created_at: Math.floor(Date.now() / 1000),
    tags: [
      ['p', userPubkey, reportType],
    ],
    content: reason || '',
  }, privateKey);
}

// 檢舉事件
function reportEvent(
  privateKey: Uint8Array,
  eventId: string,
  authorPubkey: string,
  reportType: ReportType,
  reason?: string
) {
  return finalizeEvent({
    kind: 1984,
    created_at: Math.floor(Date.now() / 1000),
    tags: [
      ['e', eventId, reportType],
      ['p', authorPubkey],
    ],
    content: reason || '',
  }, privateKey);
}

// 檢舉媒體
function reportMedia(
  privateKey: Uint8Array,
  fileHash: string,
  eventId: string,
  authorPubkey: string,
  reportType: ReportType,
  serverUrl?: string,
  reason?: string
) {
  const tags: string[][] = [
    ['x', fileHash],
    ['e', eventId],
    ['p', authorPubkey],
  ];

  if (serverUrl) {
    tags.push(['server', serverUrl]);
  }

  return finalizeEvent({
    kind: 1984,
    created_at: Math.floor(Date.now() / 1000),
    tags,
    content: reason || '',
  }, privateKey);
}

// 使用範例
const spamReport = reportUser(
  privateKey,
  'abc123...',
  'spam',
  '此帳號每小時發送數十則廣告'
);

const nudityReport = reportEvent(
  privateKey,
  'def456...',
  'ghi789...',
  'nudity',
  '貼文包含未標記的成人內容'
);`}

查詢檢舉

{`import { SimplePool } from 'nostr-tools';

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

// 查詢針對特定用戶的檢舉
async function getReportsForUser(userPubkey: string) {
  return await pool.querySync(relays, {
    kinds: [1984],
    '#p': [userPubkey],
  });
}

// 查詢針對特定事件的檢舉
async function getReportsForEvent(eventId: string) {
  return await pool.querySync(relays, {
    kinds: [1984],
    '#e': [eventId],
  });
}

// 查詢來自特定用戶的檢舉(檢舉者)
async function getReportsByUser(reporterPubkey: string) {
  return await pool.querySync(relays, {
    kinds: [1984],
    authors: [reporterPubkey],
  });
}`}

分析檢舉

{`interface ReportSummary {
  pubkey: string;
  totalReports: number;
  byType: Record;
  reporters: string[];
}

// 分析用戶的檢舉統計
function analyzeReports(reports: Event[]): ReportSummary {
  const summary: ReportSummary = {
    pubkey: '',
    totalReports: reports.length,
    byType: {
      nudity: 0,
      malware: 0,
      profanity: 0,
      illegal: 0,
      spam: 0,
      impersonation: 0,
      other: 0,
    },
    reporters: [],
  };

  for (const report of reports) {
    // 取得被檢舉者
    const pTag = report.tags.find(t => t[0] === 'p');
    if (pTag) {
      summary.pubkey = pTag[1];
      const reportType = (pTag[2] || 'other') as ReportType;
      summary.byType[reportType]++;
    }

    // 取得事件檢舉類型
    const eTag = report.tags.find(t => t[0] === 'e');
    if (eTag && eTag[2]) {
      const reportType = eTag[2] as ReportType;
      summary.byType[reportType]++;
    }

    // 記錄檢舉者
    if (!summary.reporters.includes(report.pubkey)) {
      summary.reporters.push(report.pubkey);
    }
  }

  return summary;
}`}

客戶端使用

客戶端可以根據社交圖譜來使用檢舉資訊:

{`// 檢查內容是否被朋友檢舉
async function checkFriendReports(
  eventId: string,
  myFollowing: string[]
): Promise<{
  reported: boolean;
  friendReports: Event[];
}> {
  const reports = await getReportsForEvent(eventId);

  // 過濾出朋友的檢舉
  const friendReports = reports.filter(r =>
    myFollowing.includes(r.pubkey)
  );

  return {
    reported: friendReports.length > 0,
    friendReports,
  };
}

// 根據檢舉決定顯示方式
function getContentDisplay(
  event: Event,
  friendReports: Event[]
): 'show' | 'blur' | 'hide' {
  if (friendReports.length === 0) {
    return 'show';
  }

  // 多個朋友檢舉 → 隱藏
  if (friendReports.length >= 3) {
    return 'hide';
  }

  // 單一朋友檢舉 → 模糊
  return 'blur';
}`}

中繼器使用

注意:自動審核風險

中繼器不應該自動根據檢舉進行審核,因為這容易被濫用(惡意檢舉)。 建議只根據受信任的審核者的檢舉來採取行動。

{`// 中繼器端:只信任特定審核者的檢舉
const trustedModerators = [
  'npub1moderator1...',
  'npub1moderator2...',
];

function shouldBlockContent(
  reports: Event[],
  trustedModerators: string[]
): boolean {
  // 只考慮受信任審核者的檢舉
  const trustedReports = reports.filter(r =>
    trustedModerators.includes(r.pubkey)
  );

  return trustedReports.length > 0;
}`}

React 組件範例

{`interface ReportButtonProps {
  eventId?: string;
  userPubkey: string;
  onReport: (type: ReportType, reason: string) => void;
}

function ReportButton({ eventId, userPubkey, onReport }: ReportButtonProps) {
  const [isOpen, setIsOpen] = useState(false);
  const [selectedType, setSelectedType] = useState('other');
  const [reason, setReason] = useState('');

  const reportTypes: { value: ReportType; label: string }[] = [
    { value: 'spam', label: '垃圾訊息' },
    { value: 'nudity', label: '成人內容' },
    { value: 'profanity', label: '仇恨言論' },
    { value: 'impersonation', label: '冒充他人' },
    { value: 'malware', label: '惡意軟體' },
    { value: 'illegal', label: '違法內容' },
    { value: 'other', label: '其他' },
  ];

  const handleSubmit = () => {
    onReport(selectedType, reason);
    setIsOpen(false);
    setReason('');
  };

  return (
    <>
      

      {isOpen && (
        

檢舉{eventId ? '貼文' : '用戶'}