跳至主要內容
進階 Nostr NIP-89 應用 handlers discovery

NIP-89: 應用推薦

Recommended Application Handlers - 應用發現與事件處理

10 分鐘

概述

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 事件讓應用被發現。

工作流程

1

開發者發布應用資訊

發布 kind 31990 事件,聲明支援的 kinds 和處理 URL

2

用戶發現並試用應用

客戶端查詢可用的 handlers 並展示給用戶

3

用戶推薦應用

發布 kind 31989 事件推薦喜歡的應用

4

其他用戶參考推薦

查詢關注者的推薦,優先選擇被推薦的應用

最佳實踐

✓ 提供完整的 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 生態系統中應用的互操作性。

31990
應用資訊
31989
用戶推薦
k 標籤
支援的 kinds
3 平台
web/ios/android
已複製連結
已複製到剪貼簿