NIP-89: 應用推薦
Recommended Application Handlers - 應用發現與事件處理
概述
NIP-89 定義了應用推薦系統,讓用戶可以推薦處理特定事件類型的應用程式, 同時讓應用開發者可以聲明他們的應用支援哪些事件類型。這建立了一個去中心化的應用發現機制。
核心概念: NIP-89 使用兩種事件類型:kind 31990(應用資訊)由開發者發布,聲明支援的事件類型; kind 31989(推薦)由用戶發布,推薦處理特定事件的應用。
事件類型
| Kind | 名稱 | 發布者 | 說明 |
|---|---|---|---|
| 31990 | Handler Information | 應用開發者 | 聲明應用支援的事件類型和處理方式 |
| 31989 | Recommendation | 用戶 | 推薦處理特定事件類型的應用 |
應用資訊事件(Kind 31990)
應用開發者發布此事件,聲明應用可以處理哪些事件類型:
| 標籤 | 格式 | 說明 |
|---|---|---|
| d | ["d", "<identifier>"] | 隨機唯一標識符 |
| k | ["k", "1"] | 支援的事件 kind(可多個) |
| web | ["web", "https://app.com/<bech32>"] | 網頁版 URL 模板 |
| ios | ["ios", "app://open/<bech32>"] | iOS 應用 URL scheme |
| android | ["android", "intent://..."] | Android intent URL |
URL 模板:
使用 <bech32>
作為佔位符,客戶端會將其替換為實際的 NIP-19 編碼(如 nevent、naddr、npub 等)。
推薦事件(Kind 31989)
用戶發布此事件,推薦處理特定事件類型的應用:
| 標籤 | 格式 | 說明 |
|---|---|---|
| d | ["d", "31923"] | 推薦處理的事件 kind |
| a | ["a", "31990:pubkey:d", "relay", "platform"] | 指向推薦的應用(可多個) |
web
網頁版應用
ios
iOS 應用
android
Android 應用
使用範例
應用聲明支援的事件類型
{
"kind": 31990,
"tags": [
["d", "calendar-app-v1"],
["k", "31922"],
["k", "31923"],
["k", "31925"],
["web", "https://calendar.nostr.app/<bech32>"],
["web", "https://calendar.nostr.app/<bech32>", "nevent"],
["web", "https://calendar.nostr.app/<bech32>", "naddr"],
["ios", "nostrcalendar://open/<bech32>"],
["android", "intent://open/<bech32>#Intent;scheme=nostrcalendar;end"]
],
"content": "{\"name\":\"Nostr Calendar\",\"display_name\":\"Nostr 日曆\",\"picture\":\"https://calendar.nostr.app/icon.png\",\"about\":\"管理你的 Nostr 日曆活動\"}"
} 長文閱讀器應用
{
"kind": 31990,
"tags": [
["d", "habla-reader"],
["k", "30023"],
["web", "https://habla.news/a/<bech32>", "naddr"],
["web", "https://habla.news/<bech32>"]
],
"content": "{\"name\":\"Habla\",\"display_name\":\"Habla\",\"picture\":\"https://habla.news/logo.png\",\"about\":\"Nostr 長文閱讀與寫作平台\"}"
} 用戶推薦日曆應用
{
"kind": 31989,
"tags": [
["d", "31923"],
["a", "31990:<app-pubkey>:calendar-app-v1", "wss://relay.example.com", "web"]
],
"content": "推薦使用這個日曆應用處理活動事件"
} 推薦多個應用
{
"kind": 31989,
"tags": [
["d", "30023"],
["a", "31990:<habla-pubkey>:habla-reader", "wss://relay.example.com", "web"],
["a", "31990:<yakihonne-pubkey>:yakihonne", "wss://relay.example.com", "web"],
["a", "31990:<blogstack-pubkey>:blogstack", "wss://relay.example.com", "web"]
],
"content": "我推薦的長文閱讀應用"
} TypeScript 實作
import { finalizeEvent, generateSecretKey, getPublicKey, nip19 } from 'nostr-tools'
type Platform = 'web' | 'ios' | 'android'
interface HandlerUrl {
platform: Platform
url: string
nip19Type?: string // nevent, naddr, npub, etc.
}
interface AppMetadata {
name: string
display_name?: string
picture?: string
about?: string
}
interface HandlerOptions {
identifier: string
supportedKinds: number[]
handlers: HandlerUrl[]
metadata?: AppMetadata
}
// 創建應用資訊事件(開發者用)
function createHandlerEvent(
options: HandlerOptions,
secretKey: Uint8Array
): object {
const tags: string[][] = [
['d', options.identifier],
]
// 添加支援的 kinds
for (const kind of options.supportedKinds) {
tags.push(['k', kind.toString()])
}
// 添加處理器 URLs
for (const handler of options.handlers) {
const tag = [handler.platform, handler.url]
if (handler.nip19Type) {
tag.push(handler.nip19Type)
}
tags.push(tag)
}
const event = {
kind: 31990,
created_at: Math.floor(Date.now() / 1000),
tags,
content: options.metadata ? JSON.stringify(options.metadata) : '',
}
return finalizeEvent(event, secretKey)
}
// 創建推薦事件(用戶用)
function createRecommendation(
eventKind: number,
recommendations: { pubkey: string; identifier: string; relay?: string; platform?: Platform }[],
secretKey: Uint8Array,
comment?: string
): object {
const tags: string[][] = [
['d', eventKind.toString()],
]
for (const rec of recommendations) {
const aTag = `31990:${rec.pubkey}:${rec.identifier}`
const tag = ['a', aTag]
if (rec.relay) tag.push(rec.relay)
else if (rec.platform) tag.push('')
if (rec.platform) tag.push(rec.platform)
tags.push(tag)
}
const event = {
kind: 31989,
created_at: Math.floor(Date.now() / 1000),
tags,
content: comment || '',
}
return finalizeEvent(event, secretKey)
}
// 生成處理 URL
function buildHandlerUrl(
template: string,
eventId: string,
eventPubkey: string,
eventKind: number,
relays: string[] = []
): string {
// 根據 kind 選擇適當的 NIP-19 編碼
let bech32: string
if (eventKind >= 30000 && eventKind < 40000) {
// 可定址事件使用 naddr
bech32 = nip19.naddrEncode({
kind: eventKind,
pubkey: eventPubkey,
identifier: eventId,
relays,
})
} else {
// 一般事件使用 nevent
bech32 = nip19.neventEncode({
id: eventId,
author: eventPubkey,
relays,
})
}
return template.replace('<bech32>', bech32)
}
// 使用範例 - 開發者發布應用資訊
const appSk = generateSecretKey()
const appPk = getPublicKey(appSk)
const handlerEvent = createHandlerEvent({
identifier: 'my-calendar-app',
supportedKinds: [31922, 31923, 31925],
handlers: [
{ platform: 'web', url: 'https://mycalendar.app/<bech32>' },
{ platform: 'web', url: 'https://mycalendar.app/event/<bech32>', nip19Type: 'nevent' },
{ platform: 'ios', url: 'mycalendar://open/<bech32>' },
],
metadata: {
name: 'MyCalendar',
display_name: '我的日曆',
picture: 'https://mycalendar.app/icon.png',
about: '一個簡單的 Nostr 日曆應用',
},
}, appSk)
// 使用範例 - 用戶推薦應用
const userSk = generateSecretKey()
const recommendation = createRecommendation(
31923, // 推薦處理時間活動的應用
[
{ pubkey: appPk, identifier: 'my-calendar-app', relay: 'wss://relay.example.com', platform: 'web' }
],
userSk,
'這個日曆應用非常好用!'
)
console.log('Handler event:', handlerEvent)
console.log('Recommendation:', recommendation) 查詢應用處理器
import { SimplePool } from 'nostr-tools'
const pool = new SimplePool()
const relays = ['wss://relay.example.com']
// 查詢支援特定事件類型的應用
async function getHandlersForKind(kind: number) {
const handlers = await pool.querySync(relays, {
kinds: [31990],
'#k': [kind.toString()],
})
return handlers
}
// 查詢用戶推薦的應用
async function getRecommendationsForKind(kind: number) {
const recommendations = await pool.querySync(relays, {
kinds: [31989],
'#d': [kind.toString()],
})
return recommendations
}
// 查詢關注者推薦的應用
async function getFollowingRecommendations(
kind: number,
followingPubkeys: string[]
) {
const recommendations = await pool.querySync(relays, {
kinds: [31989],
authors: followingPubkeys,
'#d': [kind.toString()],
})
return recommendations
}
// 解析應用處理器
function parseHandler(event: any) {
const identifier = event.tags.find((t: string[]) => t[0] === 'd')?.[1]
const supportedKinds = event.tags
.filter((t: string[]) => t[0] === 'k')
.map((t: string[]) => parseInt(t[1]))
const handlers: { platform: string; url: string; nip19Type?: string }[] = []
for (const tag of event.tags) {
if (['web', 'ios', 'android'].includes(tag[0])) {
handlers.push({
platform: tag[0],
url: tag[1],
nip19Type: tag[2],
})
}
}
// 解析 metadata
let metadata = null
if (event.content) {
try {
metadata = JSON.parse(event.content)
} catch {}
}
return {
pubkey: event.pubkey,
identifier,
supportedKinds,
handlers,
metadata,
}
}
// 解析推薦
function parseRecommendation(event: any) {
const targetKind = event.tags.find((t: string[]) => t[0] === 'd')?.[1]
const recommendations = event.tags
.filter((t: string[]) => t[0] === 'a')
.map((t: string[]) => {
const [kind, pubkey, identifier] = t[1].split(':')
return {
handlerKind: parseInt(kind),
pubkey,
identifier,
relay: t[2] || undefined,
platform: t[3] || undefined,
}
})
return {
recommenderPubkey: event.pubkey,
targetKind: parseInt(targetKind),
recommendations,
comment: event.content,
}
}
// 選擇最佳處理器
function selectBestHandler(
handlers: ReturnType<typeof parseHandler>[],
platform: 'web' | 'ios' | 'android',
nip19Type?: string
) {
for (const handler of handlers) {
const matchingHandler = handler.handlers.find(h => {
if (h.platform !== platform) return false
if (nip19Type && h.nip19Type && h.nip19Type !== nip19Type) return false
return true
})
if (matchingHandler) {
return { handler, url: matchingHandler.url }
}
}
return null
}
// 使用範例
const calendarHandlers = await getHandlersForKind(31923)
console.log('日曆應用:', calendarHandlers.map(h => parseHandler(h)))
// 從關注者獲取推薦
const myFollowing = ['pubkey1', 'pubkey2']
const recommendations = await getFollowingRecommendations(31923, myFollowing)
console.log('關注者推薦:', recommendations.map(r => parseRecommendation(r))) 應用場景
📱 應用發現
用戶可以發現處理特定事件類型的應用,如日曆、長文閱讀器、音樂播放器等。
🔗 深度連結
客戶端可以使用正確的應用打開特定事件,提供無縫體驗。
⭐ 社交推薦
基於關注者的推薦,發現朋友們使用的應用。
🌐 跨平台支援
支援 Web、iOS、Android,根據用戶平台選擇合適的處理方式。
🎯 專業應用
特定事件類型可以由專門的應用處理,如直播、音樂、遊戲等。
📢 應用推廣
開發者可以透過發布 handler 事件讓應用被發現。
工作流程
開發者發布應用資訊
發布 kind 31990 事件,聲明支援的 kinds 和處理 URL
用戶發現並試用應用
客戶端查詢可用的 handlers 並展示給用戶
用戶推薦應用
發布 kind 31989 事件推薦喜歡的應用
其他用戶參考推薦
查詢關注者的推薦,優先選擇被推薦的應用
最佳實踐
✓ 提供完整的 metadata
在 content 中提供 name、picture、about 等資訊,方便用戶了解應用。
✓ 支援多種 NIP-19 類型
為不同的 NIP-19 類型(nevent、naddr 等)提供專門的 URL 模板。
✓ 多平台支援
盡可能提供 web、ios、android 多個平台的處理 URL。
⚠ 無 metadata 時使用 kind:0
如果 content 為空,客戶端應使用應用 pubkey 的 kind:0 事件獲取資訊。
相關 NIP
總結
NIP-89 建立了一個去中心化的應用發現和推薦系統。開發者可以聲明應用支援的事件類型, 用戶可以推薦喜歡的應用,客戶端可以根據事件類型選擇合適的處理方式, 實現了 Nostr 生態系統中應用的互操作性。