跳至主要內容
高級 Nostr NIP-53 直播 live streaming

NIP-53: 直播活動

Live Activities - 直播、會議室與即時互動

10 分鐘

概述

NIP-53 定義了一套用於直播活動、會議室和即時互動的事件類型。它支援視訊直播、語音房間、 線上會議等場景,包含參與者管理、聊天訊息、狀態追蹤等完整功能。

核心概念: NIP-53 使用多種事件類型協同工作:kind 30311 定義直播活動,kind 1311 用於聊天訊息, kind 30312/30313 用於會議空間,kind 10312 追蹤用戶在場狀態。

事件類型

Kind 名稱 類型 說明
30311 Live Event 可定址 直播活動主事件
1311 Live Chat 一般事件 直播聊天訊息
30312 Meeting Space 可定址 會議空間配置
30313 Meeting Room 可定址 會議房間
10312 Room Presence 可替換 用戶在場狀態

直播活動結構(Kind 30311)

直播活動事件包含豐富的元資料和參與者資訊:

標籤 格式 說明
d ["d", "<uuid>"] 唯一標識符(必要)
title ["title", "<name>"] 活動標題
summary ["summary", "<desc>"] 活動描述
image ["image", "<url>"] 預覽圖片
streaming ["streaming", "<url>"] 直播串流 URL
recording ["recording", "<url>"] 錄影回放 URL
starts ["starts", "<timestamp>"] 開始時間
ends ["ends", "<timestamp>"] 結束時間
status ["status", "live"] planned / live / ended
p ["p", "pubkey", "relay", "role", "proof"] 參與者與角色
current_participants ["current_participants", "100"] 當前觀眾數

活動狀態

📅 planned

已排程但尚未開始的活動。顯示預計開始時間,讓用戶可以預約提醒。

🔴 live

正在進行中的直播。用戶可以加入觀看和參與聊天互動。

⏹️ ended

已結束的活動。可能包含錄影回放連結供事後觀看。

注意: 如果活動超過 1 小時沒有更新,客戶端可以將其視為已結束。

參與者角色

參與者標籤格式:["p", "pubkey", "relay", "role", "proof"]

👑 Host

活動主持人,擁有完整控制權

🎤 Speaker

講者,可以發言或分享畫面

👤 Participant

一般參與者,可觀看和聊天

🛡️ Moderator

管理員,可管理聊天室

🏠 Owner

擁有者,會議空間所有人

使用範例

創建直播活動

{
  "kind": 30311,
  "tags": [
    ["d", "abc123-live-event"],
    ["title", "Nostr 開發者聚會"],
    ["summary", "討論 Nostr 協議的最新發展"],
    ["image", "https://example.com/cover.jpg"],
    ["streaming", "https://stream.example.com/live/abc123"],
    ["starts", "1704067200"],
    ["status", "planned"],
    ["p", "<host-pubkey>", "wss://relay.example.com", "Host"],
    ["p", "<speaker-pubkey>", "wss://relay.example.com", "Speaker"]
  ],
  "content": ""
}

正在直播的活動

{
  "kind": 30311,
  "tags": [
    ["d", "abc123-live-event"],
    ["title", "Nostr 開發者聚會"],
    ["summary", "討論 Nostr 協議的最新發展"],
    ["streaming", "https://stream.example.com/live/abc123"],
    ["starts", "1704067200"],
    ["status", "live"],
    ["current_participants", "256"],
    ["p", "<host-pubkey>", "wss://relay.example.com", "Host"],
    ["p", "<speaker1>", "", "Speaker"],
    ["p", "<speaker2>", "", "Speaker", "<proof-signature>"]
  ],
  "content": ""
}

直播聊天訊息

{
  "kind": 1311,
  "tags": [
    ["a", "30311:<host-pubkey>:abc123-live-event", "wss://relay.example.com"]
  ],
  "content": "這個功能太棒了!🎉"
}

已結束(含錄影)

{
  "kind": 30311,
  "tags": [
    ["d", "abc123-live-event"],
    ["title", "Nostr 開發者聚會"],
    ["status", "ended"],
    ["starts", "1704067200"],
    ["ends", "1704074400"],
    ["recording", "https://video.example.com/recordings/abc123.mp4"],
    ["total_participants", "1024"]
  ],
  "content": ""
}

TypeScript 實作

import { finalizeEvent, generateSecretKey, getPublicKey } from 'nostr-tools'
import { schnorr } from '@noble/curves/secp256k1'
import { sha256 } from '@noble/hashes/sha256'
import { bytesToHex } from '@noble/hashes/utils'

type LiveStatus = 'planned' | 'live' | 'ended'
type ParticipantRole = 'Host' | 'Speaker' | 'Participant' | 'Moderator' | 'Owner'

interface Participant {
  pubkey: string
  relay?: string
  role: ParticipantRole
  proof?: string
}

interface LiveEventOptions {
  id: string
  title: string
  summary?: string
  image?: string
  streamingUrl?: string
  recordingUrl?: string
  starts?: number
  ends?: number
  status: LiveStatus
  participants?: Participant[]
  currentParticipants?: number
  totalParticipants?: number
}

// 創建直播活動事件
function createLiveEvent(
  options: LiveEventOptions,
  secretKey: Uint8Array
): object {
  const tags: string[][] = [
    ['d', options.id],
    ['title', options.title],
    ['status', options.status],
  ]

  if (options.summary) {
    tags.push(['summary', options.summary])
  }

  if (options.image) {
    tags.push(['image', options.image])
  }

  if (options.streamingUrl) {
    tags.push(['streaming', options.streamingUrl])
  }

  if (options.recordingUrl) {
    tags.push(['recording', options.recordingUrl])
  }

  if (options.starts) {
    tags.push(['starts', options.starts.toString()])
  }

  if (options.ends) {
    tags.push(['ends', options.ends.toString()])
  }

  if (options.currentParticipants !== undefined) {
    tags.push(['current_participants', options.currentParticipants.toString()])
  }

  if (options.totalParticipants !== undefined) {
    tags.push(['total_participants', options.totalParticipants.toString()])
  }

  // 添加參與者
  if (options.participants) {
    for (const p of options.participants) {
      const pTag = ['p', p.pubkey, p.relay || '', p.role]
      if (p.proof) {
        pTag.push(p.proof)
      }
      tags.push(pTag)
    }
  }

  const event = {
    kind: 30311,
    created_at: Math.floor(Date.now() / 1000),
    tags,
    content: '',
  }

  return finalizeEvent(event, secretKey)
}

// 生成參與者證明簽名
function generateParticipantProof(
  hostPubkey: string,
  eventId: string,
  participantSecretKey: Uint8Array
): string {
  const aTag = `30311:${hostPubkey}:${eventId}`
  const hash = sha256(new TextEncoder().encode(aTag))
  const signature = schnorr.sign(hash, participantSecretKey)
  return bytesToHex(signature)
}

// 發送直播聊天訊息
function createLiveChatMessage(
  hostPubkey: string,
  eventId: string,
  message: string,
  relay: string,
  secretKey: Uint8Array
): object {
  const aTag = `30311:${hostPubkey}:${eventId}`

  const event = {
    kind: 1311,
    created_at: Math.floor(Date.now() / 1000),
    tags: [
      ['a', aTag, relay],
    ],
    content: message,
  }

  return finalizeEvent(event, secretKey)
}

// 更新活動狀態為直播中
function startLiveEvent(
  eventId: string,
  currentOptions: LiveEventOptions,
  secretKey: Uint8Array
): object {
  return createLiveEvent({
    ...currentOptions,
    id: eventId,
    status: 'live',
    starts: Math.floor(Date.now() / 1000),
  }, secretKey)
}

// 結束直播活動
function endLiveEvent(
  eventId: string,
  currentOptions: LiveEventOptions,
  recordingUrl: string,
  totalParticipants: number,
  secretKey: Uint8Array
): object {
  return createLiveEvent({
    ...currentOptions,
    id: eventId,
    status: 'ended',
    ends: Math.floor(Date.now() / 1000),
    recordingUrl,
    totalParticipants,
  }, secretKey)
}

// 使用範例
const hostSk = generateSecretKey()
const hostPk = getPublicKey(hostSk)

const speakerSk = generateSecretKey()
const speakerPk = getPublicKey(speakerSk)

const eventId = 'nostr-dev-meetup-2024'

// 創建排程活動
const plannedEvent = createLiveEvent({
  id: eventId,
  title: 'Nostr 開發者聚會',
  summary: '討論 Nostr 協議的最新發展和未來計畫',
  image: 'https://example.com/cover.jpg',
  streamingUrl: 'https://stream.example.com/live/abc123',
  starts: Math.floor(Date.now() / 1000) + 86400, // 明天
  status: 'planned',
  participants: [
    { pubkey: hostPk, role: 'Host' },
    { pubkey: speakerPk, relay: 'wss://relay.example.com', role: 'Speaker' },
  ],
}, hostSk)

// 講者生成參與證明
const proof = generateParticipantProof(hostPk, eventId, speakerSk)
console.log('Speaker proof:', proof)

// 發送聊天訊息
const chatMessage = createLiveChatMessage(
  hostPk,
  eventId,
  '大家好!期待今天的分享!🎉',
  'wss://relay.example.com',
  speakerSk
)

console.log('Planned event:', plannedEvent)
console.log('Chat message:', chatMessage)

查詢直播活動

import { SimplePool } from 'nostr-tools'

const pool = new SimplePool()
const relays = ['wss://relay.example.com']

// 查詢正在直播的活動
async function getLiveEvents() {
  const events = await pool.querySync(relays, {
    kinds: [30311],
    '#status': ['live'],
  })
  return events
}

// 查詢即將開始的活動
async function getUpcomingEvents() {
  const now = Math.floor(Date.now() / 1000)
  const events = await pool.querySync(relays, {
    kinds: [30311],
    '#status': ['planned'],
  })

  // 過濾出未來的活動
  return events.filter(event => {
    const startsTag = event.tags.find(t => t[0] === 'starts')
    if (!startsTag) return true
    return parseInt(startsTag[1]) > now
  })
}

// 查詢特定活動的聊天訊息
async function getLiveChatMessages(hostPubkey: string, eventId: string) {
  const aTag = `30311:${hostPubkey}:${eventId}`
  const messages = await pool.querySync(relays, {
    kinds: [1311],
    '#a': [aTag],
  })
  return messages.sort((a, b) => a.created_at - b.created_at)
}

// 訂閱直播聊天
function subscribeLiveChat(
  hostPubkey: string,
  eventId: string,
  onMessage: (message: any) => void
) {
  const aTag = `30311:${hostPubkey}:${eventId}`

  return pool.subscribeMany(
    relays,
    [{
      kinds: [1311],
      '#a': [aTag],
      since: Math.floor(Date.now() / 1000),
    }],
    {
      onevent(event) {
        onMessage(event)
      },
    }
  )
}

// 解析直播活動
function parseLiveEvent(event: any) {
  const getTag = (name: string) =>
    event.tags.find((t: string[]) => t[0] === name)?.[1]

  const participants = event.tags
    .filter((t: string[]) => t[0] === 'p')
    .map((t: string[]) => ({
      pubkey: t[1],
      relay: t[2] || undefined,
      role: t[3] || 'Participant',
      proof: t[4] || undefined,
    }))

  return {
    id: getTag('d'),
    title: getTag('title'),
    summary: getTag('summary'),
    image: getTag('image'),
    streamingUrl: getTag('streaming'),
    recordingUrl: getTag('recording'),
    starts: getTag('starts') ? parseInt(getTag('starts')!) : undefined,
    ends: getTag('ends') ? parseInt(getTag('ends')!) : undefined,
    status: getTag('status') as LiveStatus,
    currentParticipants: getTag('current_participants')
      ? parseInt(getTag('current_participants')!)
      : undefined,
    participants,
    host: event.pubkey,
    createdAt: event.created_at,
  }
}

// 使用範例
const liveEvents = await getLiveEvents()
console.log('正在直播:', liveEvents.map(e => parseLiveEvent(e)))

const upcoming = await getUpcomingEvents()
console.log('即將開始:', upcoming.map(e => parseLiveEvent(e)))

應用場景

📺 視訊直播

類似 YouTube Live 或 Twitch 的直播平台,支援即時聊天互動。

🎙️ 語音房間

類似 Twitter Spaces 或 Clubhouse 的語音聊天室,如 Nostr Nests。

💼 線上會議

去中心化的視訊會議,支援多個房間和會議排程。

🎓 線上課程

教育直播,支援課程排程、學員互動和錄影回放。

🎮 遊戲直播

遊戲實況直播,整合打賞(Zaps)功能。

🎵 音樂表演

現場音樂演出直播,觀眾可以透過 Zaps 打賞藝人。

最佳實踐

✓ 定期更新活動狀態

直播期間定期更新事件(建議每 5-10 分鐘),更新觀眾數等資訊,避免被視為已結束。

✓ 使用參與者證明

講者應提供 proof 簽名,證明他們同意被列為參與者,防止惡意主持人冒名。

✓ 保留錄影連結

活動結束後更新事件,添加 recording 標籤讓錯過直播的用戶可以回看。

⚠ 限制參與者列表大小

建議參與者列表不超過 1000 人,大型活動應使用 current_participants 數字而非列出所有人。

⚠ 聊天訊息必須包含 a 標籤

kind 1311 聊天訊息必須包含指向活動的 a 標籤,否則無法正確關聯。

相關 NIP

總結

NIP-53 提供了一套完整的直播活動解決方案,支援視訊直播、語音房間、線上會議等多種場景。 透過狀態管理、參與者角色和即時聊天,打造去中心化的互動體驗。

30311
直播活動
1311
聊天訊息
3 種
活動狀態
5 種
參與者角色
已複製連結
已複製到剪貼簿