跳至主要內容
進階 Nostr NIP-52 日曆 calendar events

NIP-52: 日曆活動

Calendar Events - 去中心化活動排程與 RSVP

10 分鐘

概述

NIP-52 定義了日曆活動系統,允許用戶創建、分享和管理活動。它支援全天活動和指定時間的活動, 包含 RSVP(回覆出席)機制、地點標記、參與者管理等完整功能,實現去中心化的活動排程。

核心概念: NIP-52 使用三種主要事件類型:kind 31922(日期活動)、kind 31923(時間活動)和 kind 31925(RSVP), 並可透過 kind 31924 日曆列表組織多個活動。

事件類型

Kind 名稱 類型 說明
31922 Date-based Event 可定址 全天或多日活動
31923 Time-based Event 可定址 指定時間的活動
31924 Calendar 可定址 日曆列表(活動集合)
31925 RSVP 可定址 出席回覆

日期活動(Kind 31922)

用於全天或跨多日的活動,不涉及具體時間和時區:

標籤 必要 格式 說明
d ["d", "<uuid>"] 唯一標識符
title ["title", "活動名稱"] 活動標題
start ["start", "2024-12-25"] 開始日期(ISO 8601)
end - ["end", "2024-12-26"] 結束日期(多日活動)

時間活動(Kind 31923)

用於有具體時間的活動,支援時區設定:

標籤 必要 格式 說明
d ["d", "<uuid>"] 唯一標識符
title ["title", "活動名稱"] 活動標題
start ["start", "1703487600"] 開始時間(Unix 時間戳)
end - ["end", "1703494800"] 結束時間
start_tzid - ["start_tzid", "Asia/Taipei"] 開始時間時區
end_tzid - ["end_tzid", "Asia/Taipei"] 結束時間時區

通用標籤

以下標籤適用於日期活動和時間活動:

標籤 格式 說明
summary ["summary", "簡短描述"] 活動摘要
image ["image", "https://..."] 活動圖片
location ["location", "台北市..."] 活動地點
g ["g", "wsqqk"] Geohash(地理位置搜尋)
p ["p", "pubkey", "relay", "role"] 參與者
t ["t", "meetup"] 主題標籤
r ["r", "https://meet.example.com"] 外部連結(視訊會議等)

RSVP 回覆(Kind 31925)

用戶可以透過 RSVP 事件回覆活動邀請:

accepted

確認參加

declined

無法參加

? tentative

待定,可能參加

標籤 必要 說明
a 指向活動的座標
d 與 a 標籤相同的座標
status accepted / declined / tentative
fb - free / busy(顯示忙碌狀態)

使用範例

全天活動(日期)

{
  "kind": 31922,
  "tags": [
    ["d", "christmas-2024"],
    ["title", "聖誕節"],
    ["start", "2024-12-25"],
    ["image", "https://example.com/christmas.jpg"]
  ],
  "content": "聖誕節快樂!🎄"
}

多日活動

{
  "kind": 31922,
  "tags": [
    ["d", "bitcoin-conf-2024"],
    ["title", "Bitcoin 2024 大會"],
    ["start", "2024-07-25"],
    ["end", "2024-07-27"],
    ["location", "Nashville, Tennessee"],
    ["g", "dnmw7"],
    ["image", "https://example.com/btc-conf.jpg"],
    ["t", "bitcoin"],
    ["t", "conference"]
  ],
  "content": "年度比特幣盛會,匯聚全球開發者與愛好者"
}

指定時間活動

{
  "kind": 31923,
  "tags": [
    ["d", "nostr-meetup-taipei"],
    ["title", "Nostr 開發者聚會 - 台北"],
    ["start", "1704042000"],
    ["end", "1704049200"],
    ["start_tzid", "Asia/Taipei"],
    ["end_tzid", "Asia/Taipei"],
    ["location", "台北市信義區某咖啡廳"],
    ["g", "wsqqk"],
    ["summary", "每月一次的 Nostr 開發者交流聚會"],
    ["r", "https://meet.example.com/nostr-taipei"],
    ["p", "<organizer-pubkey>", "wss://relay.example.com", "organizer"],
    ["p", "<speaker-pubkey>", "", "speaker"],
    ["t", "nostr"],
    ["t", "meetup"]
  ],
  "content": "本月主題:NIP-52 日曆活動實作分享"
}

RSVP 回覆

{
  "kind": 31925,
  "tags": [
    ["a", "31923:<organizer-pubkey>:nostr-meetup-taipei"],
    ["d", "31923:<organizer-pubkey>:nostr-meetup-taipei"],
    ["status", "accepted"],
    ["fb", "busy"]
  ],
  "content": "期待參加!我會帶一些 Nostr 貼紙 🎉"
}

日曆列表

{
  "kind": 31924,
  "tags": [
    ["d", "my-tech-calendar"],
    ["title", "科技活動日曆"],
    ["a", "31923:<pubkey1>:nostr-meetup-taipei"],
    ["a", "31922:<pubkey2>:bitcoin-conf-2024"],
    ["a", "31923:<pubkey3>:eth-hackathon"]
  ],
  "content": "收集各種科技和區塊鏈相關活動"
}

TypeScript 實作

import { finalizeEvent, generateSecretKey, getPublicKey } from 'nostr-tools'

type RSVPStatus = 'accepted' | 'declined' | 'tentative'
type FreeBusy = 'free' | 'busy'

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

interface DateEventOptions {
  id: string
  title: string
  startDate: string  // YYYY-MM-DD
  endDate?: string
  summary?: string
  image?: string
  location?: string
  geohash?: string
  participants?: Participant[]
  hashtags?: string[]
  links?: string[]
  content?: string
}

interface TimeEventOptions {
  id: string
  title: string
  startTime: number  // Unix timestamp
  endTime?: number
  startTimezone?: string  // IANA timezone
  endTimezone?: string
  summary?: string
  image?: string
  location?: string
  geohash?: string
  participants?: Participant[]
  hashtags?: string[]
  links?: string[]
  content?: string
}

// 創建日期活動
function createDateEvent(
  options: DateEventOptions,
  secretKey: Uint8Array
): object {
  const tags: string[][] = [
    ['d', options.id],
    ['title', options.title],
    ['start', options.startDate],
  ]

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

  addCommonTags(tags, options)

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

  return finalizeEvent(event, secretKey)
}

// 創建時間活動
function createTimeEvent(
  options: TimeEventOptions,
  secretKey: Uint8Array
): object {
  const tags: string[][] = [
    ['d', options.id],
    ['title', options.title],
    ['start', options.startTime.toString()],
  ]

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

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

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

  addCommonTags(tags, options)

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

  return finalizeEvent(event, secretKey)
}

// 添加通用標籤
function addCommonTags(
  tags: string[][],
  options: Partial<DateEventOptions & TimeEventOptions>
) {
  if (options.summary) {
    tags.push(['summary', options.summary])
  }
  if (options.image) {
    tags.push(['image', options.image])
  }
  if (options.location) {
    tags.push(['location', options.location])
  }
  if (options.geohash) {
    tags.push(['g', options.geohash])
  }
  if (options.participants) {
    for (const p of options.participants) {
      const pTag = ['p', p.pubkey]
      if (p.relay) pTag.push(p.relay)
      else if (p.role) pTag.push('')
      if (p.role) pTag.push(p.role)
      tags.push(pTag)
    }
  }
  if (options.hashtags) {
    for (const tag of options.hashtags) {
      tags.push(['t', tag])
    }
  }
  if (options.links) {
    for (const link of options.links) {
      tags.push(['r', link])
    }
  }
}

// 創建 RSVP
function createRSVP(
  eventKind: 31922 | 31923,
  eventPubkey: string,
  eventId: string,
  status: RSVPStatus,
  secretKey: Uint8Array,
  options?: { freeBusy?: FreeBusy; comment?: string }
): object {
  const aTag = `${eventKind}:${eventPubkey}:${eventId}`

  const tags: string[][] = [
    ['a', aTag],
    ['d', aTag],
    ['status', status],
  ]

  if (options?.freeBusy) {
    tags.push(['fb', options.freeBusy])
  }

  const event = {
    kind: 31925,
    created_at: Math.floor(Date.now() / 1000),
    tags,
    content: options?.comment || '',
  }

  return finalizeEvent(event, secretKey)
}

// 創建日曆列表
function createCalendar(
  id: string,
  title: string,
  eventRefs: { kind: 31922 | 31923; pubkey: string; id: string }[],
  secretKey: Uint8Array
): object {
  const tags: string[][] = [
    ['d', id],
    ['title', title],
  ]

  for (const ref of eventRefs) {
    tags.push(['a', `${ref.kind}:${ref.pubkey}:${ref.id}`])
  }

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

  return finalizeEvent(event, secretKey)
}

// 使用範例
const sk = generateSecretKey()
const pk = getPublicKey(sk)

// 創建全天活動
const dateEvent = createDateEvent({
  id: 'bitcoin-pizza-day',
  title: 'Bitcoin Pizza Day',
  startDate: '2024-05-22',
  summary: '紀念第一筆比特幣實體交易',
  hashtags: ['bitcoin', 'pizzaday'],
}, sk)

// 創建指定時間活動
const timeEvent = createTimeEvent({
  id: 'nostr-workshop',
  title: 'Nostr 開發工作坊',
  startTime: Math.floor(Date.now() / 1000) + 86400 * 7,
  endTime: Math.floor(Date.now() / 1000) + 86400 * 7 + 7200,
  startTimezone: 'Asia/Taipei',
  endTimezone: 'Asia/Taipei',
  location: '線上',
  links: ['https://meet.example.com/workshop'],
  participants: [
    { pubkey: pk, role: 'organizer' },
  ],
  content: '學習如何開發 Nostr 客戶端',
}, sk)

// RSVP 確認參加
const rsvp = createRSVP(
  31923,
  pk,
  'nostr-workshop',
  'accepted',
  sk,
  { freeBusy: 'busy', comment: '我會準時參加!' }
)

console.log('Date event:', dateEvent)
console.log('Time event:', timeEvent)
console.log('RSVP:', rsvp)

查詢活動

import { SimplePool } from 'nostr-tools'

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

// 查詢用戶的所有活動
async function getUserEvents(pubkey: string) {
  const events = await pool.querySync(relays, {
    kinds: [31922, 31923],
    authors: [pubkey],
  })
  return events
}

// 查詢特定地區的活動(使用 geohash)
async function getEventsByLocation(geohashPrefix: string) {
  const events = await pool.querySync(relays, {
    kinds: [31922, 31923],
    '#g': [geohashPrefix],
  })
  return events
}

// 查詢特定主題的活動
async function getEventsByTag(tag: string) {
  const events = await pool.querySync(relays, {
    kinds: [31922, 31923],
    '#t': [tag],
  })
  return events
}

// 查詢活動的 RSVP
async function getEventRSVPs(eventKind: number, eventPubkey: string, eventId: string) {
  const aTag = `${eventKind}:${eventPubkey}:${eventId}`
  const rsvps = await pool.querySync(relays, {
    kinds: [31925],
    '#a': [aTag],
  })
  return rsvps
}

// 統計 RSVP
function countRSVPs(rsvps: any[]): {
  accepted: number
  declined: number
  tentative: number
  total: number
} {
  const counts = { accepted: 0, declined: 0, tentative: 0, total: rsvps.length }

  for (const rsvp of rsvps) {
    const statusTag = rsvp.tags.find((t: string[]) => t[0] === 'status')
    const status = statusTag?.[1]
    if (status === 'accepted') counts.accepted++
    else if (status === 'declined') counts.declined++
    else if (status === 'tentative') counts.tentative++
  }

  return counts
}

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

  const isDateBased = event.kind === 31922

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

  const hashtags = event.tags
    .filter((t: string[]) => t[0] === 't')
    .map((t: string[]) => t[1])

  return {
    id: getTag('d'),
    title: getTag('title'),
    start: isDateBased ? getTag('start') : parseInt(getTag('start') || '0'),
    end: isDateBased ? getTag('end') : (getTag('end') ? parseInt(getTag('end')!) : undefined),
    startTimezone: getTag('start_tzid'),
    endTimezone: getTag('end_tzid'),
    summary: getTag('summary'),
    image: getTag('image'),
    location: getTag('location'),
    geohash: getTag('g'),
    participants,
    hashtags,
    content: event.content,
    isDateBased,
    pubkey: event.pubkey,
    createdAt: event.created_at,
  }
}

應用場景

📅 個人日曆

管理個人活動、約會和提醒,跨客戶端同步。

🎉 社群聚會

發布 Meetup、黑客松、研討會等社群活動。

🏢 會議排程

團隊會議、視訊通話排程,整合 RSVP 確認。

🎵 演出活動

音樂會、表演活動發布,結合地點搜尋。

📺 直播預告

結合 NIP-53,預告直播活動時間。

🗓️ 公共日曆

創建主題日曆(如區塊鏈活動),讓其他人訂閱。

最佳實踐

✓ 使用正確的活動類型

全天活動使用 kind 31922(日期),有具體時間的活動使用 kind 31923(時間)。

✓ 包含時區資訊

時間活動應包含 start_tzid 標籤,幫助不同時區的用戶正確顯示時間。

✓ 添加 geohash 方便搜尋

實體活動應包含 g 標籤(geohash),讓用戶可以搜尋附近的活動。

⚠ 不支援重複活動

NIP-52 目前不支援重複活動(如每週會議),需要為每次活動創建獨立事件。

⚠ RSVP 的 d 標籤

RSVP 事件的 d 標籤應與 a 標籤相同,確保每個用戶對同一活動只有一個 RSVP。

相關 NIP

總結

NIP-52 為 Nostr 提供了完整的日曆活動系統,支援全天活動、指定時間活動、RSVP 回覆和日曆列表。 透過 geohash 和主題標籤,用戶可以輕鬆發現和參與感興趣的活動。

31922
日期活動
31923
時間活動
31925
RSVP
31924
日曆列表
已複製連結
已複製到剪貼簿