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