跳至主要內容
入門

NIP-02 聯絡人列表

深入了解 Nostr 的聯絡人列表標準,使用 kind 3 事件儲存和分享關注列表。

8 分鐘

什麼是 NIP-02?

NIP-02 定義了 Nostr 的聯絡人列表(關注列表)標準。用戶使用 kind 3 事件 來儲存他們關注的人的公鑰列表。這個列表是可替換的,新的 kind 3 事件 會取代舊的,讓用戶可以隨時更新關注名單。

核心功能: 聯絡人列表是 Nostr 社交圖譜的基礎,決定了你的動態時報顯示哪些人的內容, 也讓其他用戶可以發現你關注的人。

事件結構

{
  "kind": 3,
  "content": "",
  "tags": [
    ["p", "<pubkey-1>", "<relay-url>", "<petname>"],
    ["p", "<pubkey-2>", "<relay-url>", "<petname>"],
    ["p", "<pubkey-3>", "<relay-url>", "<petname>"],
    ...
  ],
  "pubkey": "<你的公鑰>",
  "created_at": 1704067200,
  "id": "...",
  "sig": "..."
}

// 實際範例
{
  "kind": 3,
  "content": "",
  "tags": [
    ["p", "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d", "wss://relay.damus.io", "fiatjaf"],
    ["p", "82341f882b6eabcd2ba7f1ef90aad961cf074af15b9ef44a09f9d2a8fbfbe6a2", "wss://nos.lol", "jack"],
    ["p", "npub1sg6plzptd64u62a878hep2kev88swjh3tw00gjsfl8f237lmu63q0uf63m", "", ""]
  ],
  "pubkey": "...",
  "created_at": 1704067200,
  "id": "...",
  "sig": "..."
}

標籤格式

位置 內容 必須 說明
[0] "p" 標籤類型(pubkey)
[1] 公鑰 關注者的 32-byte 十六進位公鑰
[2] 中繼器 URL 可選 可以找到此用戶的中繼器
[3] 暱稱 可選 本地暱稱(petname)

可替換事件

Kind 3 是「可替換事件」(Replaceable Event),這意味著:

// 可替換事件的特性:
// 1. 同一用戶只保留最新的 kind 3 事件
// 2. 新事件會完全取代舊事件
// 3. 中繼器根據 created_at 判斷新舊

// 例如:用戶原本關注 3 個人
Event A (created_at: 1000):
  tags: [["p", "alice"], ["p", "bob"], ["p", "carol"]]

// 用戶新增一個關注,發布新事件
Event B (created_at: 2000):
  tags: [["p", "alice"], ["p", "bob"], ["p", "carol"], ["p", "dave"]]

// Event B 會取代 Event A
// 注意:必須包含所有關注者,不是增量更新!

重要: 更新關注列表時,必須發布包含所有關注者的新事件。 如果只發布部分列表,其他人會被取消關注!

程式碼範例

取得關注列表

async function getFollowList(relay, pubkey) {
  return new Promise((resolve) => {
    let contactList = null

    const sub = relay.subscribe([{
      kinds: [3],
      authors: [pubkey],
      limit: 1  // 只需要最新的一個
    }])

    sub.on('event', (event) => {
      // 如果已有事件,比較時間戳
      if (!contactList || event.created_at > contactList.created_at) {
        contactList = event
      }
    })

    sub.on('eose', () => {
      sub.close()

      if (!contactList) {
        resolve({ follows: [], relayHints: {} })
        return
      }

      // 解析關注列表
      const follows = []
      const relayHints = {}

      for (const tag of contactList.tags) {
        if (tag[0] === 'p') {
          const [, pubkey, relay, petname] = tag
          follows.push({
            pubkey,
            relay: relay || null,
            petname: petname || null
          })

          if (relay) {
            relayHints[pubkey] = relay
          }
        }
      }

      resolve({ follows, relayHints, event: contactList })
    })
  })
}

// 使用
const { follows, relayHints } = await getFollowList(relay, myPubkey)
console.log(`關注了 ${follows.length} 個人`)
follows.forEach(f => {
  console.log(`- ${f.petname || f.pubkey.slice(0, 8)}`)
})

更新關注列表

async function updateFollowList(relay, currentFollows, action, targetPubkey, options = {}) {
  // action: 'add' | 'remove'
  let newFollows = [...currentFollows]

  if (action === 'add') {
    // 檢查是否已關注
    if (newFollows.some(f => f.pubkey === targetPubkey)) {
      console.log('已經關注了')
      return null
    }

    newFollows.push({
      pubkey: targetPubkey,
      relay: options.relay || null,
      petname: options.petname || null
    })
  } else if (action === 'remove') {
    newFollows = newFollows.filter(f => f.pubkey !== targetPubkey)
  }

  // 建立新的 kind 3 事件
  const event = {
    kind: 3,
    content: '',  // 通常留空,但可以存放其他資料
    created_at: Math.floor(Date.now() / 1000),
    tags: newFollows.map(f => {
      const tag = ['p', f.pubkey]
      if (f.relay) tag.push(f.relay)
      else tag.push('')
      if (f.petname) tag.push(f.petname)
      return tag
    })
  }

  // 簽名並發布
  const signedEvent = await window.nostr.signEvent(event)
  await relay.publish(signedEvent)

  console.log(`關注列表已更新,現在關注 ${newFollows.length} 人`)
  return signedEvent
}

// 關注某人
await updateFollowList(relay, currentFollows, 'add', 'abc123...', {
  relay: 'wss://relay.example.com',
  petname: 'Alice'
})

// 取消關注
await updateFollowList(relay, currentFollows, 'remove', 'abc123...')

取得關注者的動態

async function getFollowingFeed(relay, myPubkey, limit = 50) {
  // 1. 先取得關注列表
  const { follows, relayHints } = await getFollowList(relay, myPubkey)

  if (follows.length === 0) {
    console.log('還沒有關注任何人')
    return []
  }

  // 2. 取得關注者的公鑰列表
  const followPubkeys = follows.map(f => f.pubkey)

  // 3. 查詢他們的貼文
  return new Promise((resolve) => {
    const posts = []

    const sub = relay.subscribe([{
      kinds: [1],  // 一般貼文
      authors: followPubkeys,
      limit: limit
    }])

    sub.on('event', (event) => {
      posts.push(event)
    })

    sub.on('eose', () => {
      sub.close()
      // 按時間排序
      posts.sort((a, b) => b.created_at - a.created_at)
      resolve(posts)
    })
  })
}

// 使用
const feed = await getFollowingFeed(relay, myPubkey, 100)
console.log(`取得 ${feed.length} 則動態`)

檢查關注關係

async function checkFollowRelationship(relay, userA, userB) {
  const [aFollows, bFollows] = await Promise.all([
    getFollowList(relay, userA),
    getFollowList(relay, userB)
  ])

  const aFollowsB = aFollows.follows.some(f => f.pubkey === userB)
  const bFollowsA = bFollows.follows.some(f => f.pubkey === userA)

  return {
    aFollowsB,
    bFollowsA,
    mutualFollow: aFollowsB && bFollowsA
  }
}

// 使用
const relation = await checkFollowRelationship(relay, myPubkey, otherPubkey)
if (relation.mutualFollow) {
  console.log('互相關注!')
} else if (relation.aFollowsB) {
  console.log('你關注了對方')
} else if (relation.bFollowsA) {
  console.log('對方關注了你')
}

Petname 系統

Petname(暱稱)讓用戶可以為關注的人設定本地名稱:

// Petname 的用途:
// 1. 為公鑰設定易記的名稱
// 2. 本地覆蓋用戶的 profile name
// 3. 防止冒充(用自己的命名)

// 範例:你設定的 petname
["p", "abc123...", "wss://relay.example.com", "我的好友小明"]

// 即使對方的 profile 名稱是 "Bitcoin Maximalist"
// 在你的客戶端中會顯示 "我的好友小明"

// 這提供了一層防詐騙保護
// 即使有人冒充你朋友,你仍會看到正確的 petname

中繼器提示

中繼器 URL 欄位是「社交圖譜路由」的基礎:

// 中繼器提示的用途:
// 1. 告訴客戶端在哪裡可以找到這個用戶的內容
// 2. 實現去中心化的用戶發現
// 3. 減少對單一中繼器的依賴

// 使用中繼器提示
async function fetchUserContent(pubkey, relayHint) {
  // 如果有提示,優先使用提示的中繼器
  const relayUrl = relayHint || 'wss://relay.damus.io'

  const relay = await connectToRelay(relayUrl)
  const events = await queryUserEvents(relay, pubkey)

  return events
}

// 結合 NIP-65 可以獲得更準確的中繼器資訊

Content 欄位

雖然 NIP-02 的 content 通常留空,但有些客戶端會用它來儲存額外資訊:

// 有些客戶端在 content 中儲存中繼器偏好設定
{
  "kind": 3,
  "content": JSON.stringify({
    "wss://relay.damus.io": { "read": true, "write": true },
    "wss://nos.lol": { "read": true, "write": false }
  }),
  "tags": [...]
}

// 注意:這種用法已被 NIP-65 取代
// 現代客戶端應該使用 kind 10002 來儲存中繼器列表

最佳實踐

建議做法

  • • 更新前先讀取現有列表
  • • 提供中繼器提示改善發現
  • • 使用 petname 防止冒充
  • • 發布到多個中繼器備份

避免問題

  • • 不要遺漏現有關注者
  • • 避免頻繁更新(合併操作)
  • • 不要在 content 存關鍵資料
  • • 處理大型列表時注意效能

與其他 NIP 的關係

NIP 關係
NIP-01 基礎事件格式
NIP-65 現代的中繼器列表標準(取代 content 中的中繼器)
NIP-51 更多列表類型(靜音、書籤等)

提示: NIP-02 是 Nostr 最早的 NIP 之一,幾乎所有客戶端都支援。 它是建立社交網路的基礎,讓用戶可以追蹤喜歡的人的內容。

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