入門
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 之一,幾乎所有客戶端都支援。 它是建立社交網路的基礎,讓用戶可以追蹤喜歡的人的內容。
已複製連結