跳至主要內容
進階

NIP-28 公開聊天

Nostr 公開聊天頻道標準,實現去中心化的群組聊天室功能。

12 分鐘

概述

NIP-28 定義了 Nostr 的公開聊天頻道標準,類似於 IRC 頻道或 Discord 公開伺服器。 任何人都可以建立頻道、發送訊息、回覆討論。頻道是完全公開的, 所有訊息都儲存在中繼器上。

去中心化聊天

與傳統聊天室不同,Nostr 頻道沒有中央伺服器。頻道由創建者的事件定義, 訊息分散在多個中繼器上,任何人都可以參與。

事件類型

NIP-28 保留了 kind 40-44 用於公開聊天:

Kind 名稱 說明
40 頻道建立 建立新的聊天頻道
41 頻道中繼資料 更新頻道資訊
42 頻道訊息 在頻道中發送訊息
43 隱藏訊息 隱藏特定訊息(個人)
44 靜音用戶 靜音特定用戶(個人)

建立頻道 (Kind 40)

Kind 40 事件用於建立新頻道。頻道 ID 就是這個事件的 ID。 內容是 JSON 格式的頻道中繼資料。

{`{
  "kind": 40,
  "content": "{\"name\":\"Nostr 開發者\",\"about\":\"討論 Nostr 開發相關話題\",\"picture\":\"https://example.com/channel.png\"}",
  "tags": [
    ["t", "nostr"],
    ["t", "development"]
  ],
  ...
}`}

中繼資料欄位

  • name - 頻道名稱
  • about - 頻道描述
  • picture - 頻道圖片 URL
  • relays - 建議的中繼器列表(選填)

更新頻道資訊 (Kind 41)

頻道創建者可以發布 kind 41 事件來更新頻道資訊。 客戶端應該只接受來自頻道創建者的 kind 41 事件。

{`{
  "kind": 41,
  "content": "{\"name\":\"Nostr 開發者(官方)\",\"about\":\"更新後的描述\"}",
  "tags": [
    ["e", "<頻道ID>", "<中繼器>"]
  ],
  ...
}`}

權限控制

客戶端應該忽略非頻道創建者發布的 kind 41 事件。 只有最新的 kind 41 事件會被使用。

發送訊息 (Kind 42)

Kind 42 事件用於在頻道中發送訊息。使用 NIP-10 標記的 e 標籤 來標識頻道和回覆關係。

發送新訊息

{`{
  "kind": 42,
  "content": "大家好!這是我的第一則頻道訊息",
  "tags": [
    ["e", "<頻道ID>", "<中繼器>", "root"]
  ],
  ...
}`}

回覆訊息

{`{
  "kind": 42,
  "content": "歡迎!有什麼問題都可以問",
  "tags": [
    ["e", "<頻道ID>", "<中繼器>", "root"],
    ["e", "<回覆的訊息ID>", "<中繼器>", "reply"],
    ["p", "<被回覆者公鑰>"]
  ],
  ...
}`}

管理功能

隱藏訊息 (Kind 43)

用戶可以發布 kind 43 事件來隱藏特定訊息。這是個人設定, 只影響發布者自己看到的內容。

{`{
  "kind": 43,
  "content": "{\"reason\":\"垃圾訊息\"}",
  "tags": [
    ["e", "<要隱藏的訊息ID>"]
  ],
  ...
}`}

靜音用戶 (Kind 44)

用戶可以發布 kind 44 事件來靜音特定用戶。 該用戶在頻道中的所有訊息都會被隱藏。

{`{
  "kind": 44,
  "content": "{\"reason\":\"騷擾行為\"}",
  "tags": [
    ["p", "<要靜音的用戶公鑰>"]
  ],
  ...
}`}

實作

建立頻道

{`import { finalizeEvent } from 'nostr-tools';

interface ChannelMetadata {
  name: string;
  about?: string;
  picture?: string;
  relays?: string[];
}

// 建立新頻道
function createChannel(
  privateKey: Uint8Array,
  metadata: ChannelMetadata,
  tags?: string[]
) {
  const content = JSON.stringify({
    name: metadata.name,
    about: metadata.about || '',
    picture: metadata.picture || '',
    ...(metadata.relays && { relays: metadata.relays }),
  });

  const eventTags = tags || [];

  return finalizeEvent({
    kind: 40,
    created_at: Math.floor(Date.now() / 1000),
    tags: eventTags,
    content,
  }, privateKey);
}

// 使用範例
const channel = createChannel(
  privateKey,
  {
    name: 'Bitcoin 討論區',
    about: '討論比特幣技術和市場',
    picture: 'https://example.com/btc.png',
  },
  [['t', 'bitcoin'], ['t', 'crypto']]
);

// channel.id 就是頻道 ID`}

更新頻道資訊

{`// 更新頻道中繼資料(只有創建者可以)
function updateChannelMetadata(
  privateKey: Uint8Array,
  channelId: string,
  relay: string,
  metadata: Partial
) {
  return finalizeEvent({
    kind: 41,
    created_at: Math.floor(Date.now() / 1000),
    tags: [['e', channelId, relay]],
    content: JSON.stringify(metadata),
  }, privateKey);
}`}

發送頻道訊息

{`interface MessageOptions {
  channelId: string;
  relay: string;
  content: string;
  replyTo?: {
    eventId: string;
    pubkey: string;
  };
}

// 發送頻道訊息
function sendChannelMessage(
  privateKey: Uint8Array,
  options: MessageOptions
) {
  const tags: string[][] = [
    ['e', options.channelId, options.relay, 'root'],
  ];

  // 如果是回覆
  if (options.replyTo) {
    tags.push(['e', options.replyTo.eventId, options.relay, 'reply']);
    tags.push(['p', options.replyTo.pubkey]);
  }

  return finalizeEvent({
    kind: 42,
    created_at: Math.floor(Date.now() / 1000),
    tags,
    content: options.content,
  }, privateKey);
}

// 發送新訊息
const message = sendChannelMessage(privateKey, {
  channelId: 'abc123...',
  relay: 'wss://relay.example.com',
  content: '大家好!',
});

// 回覆訊息
const reply = sendChannelMessage(privateKey, {
  channelId: 'abc123...',
  relay: 'wss://relay.example.com',
  content: '歡迎加入!',
  replyTo: {
    eventId: 'def456...',
    pubkey: 'ghi789...',
  },
});`}

隱藏和靜音

{`// 隱藏特定訊息
function hideMessage(
  privateKey: Uint8Array,
  messageId: string,
  reason?: string
) {
  return finalizeEvent({
    kind: 43,
    created_at: Math.floor(Date.now() / 1000),
    tags: [['e', messageId]],
    content: reason ? JSON.stringify({ reason }) : '',
  }, privateKey);
}

// 靜音用戶
function muteUser(
  privateKey: Uint8Array,
  userPubkey: string,
  reason?: string
) {
  return finalizeEvent({
    kind: 44,
    created_at: Math.floor(Date.now() / 1000),
    tags: [['p', userPubkey]],
    content: reason ? JSON.stringify({ reason }) : '',
  }, privateKey);
}`}

查詢頻道

{`import { SimplePool } from 'nostr-tools';

const pool = new SimplePool();
const relays = ['wss://relay.damus.io', 'wss://nos.lol'];

// 查詢所有頻道
async function getAllChannels() {
  return await pool.querySync(relays, {
    kinds: [40],
  });
}

// 查詢特定頻道的資訊
async function getChannelInfo(channelId: string) {
  // 取得頻道建立事件
  const createEvent = await pool.get(relays, {
    ids: [channelId],
  });

  if (!createEvent) return null;

  // 取得最新的中繼資料更新
  const metadataEvents = await pool.querySync(relays, {
    kinds: [41],
    '#e': [channelId],
    authors: [createEvent.pubkey], // 只接受創建者的更新
  });

  // 使用最新的中繼資料
  const latestMetadata = metadataEvents
    .sort((a, b) => b.created_at - a.created_at)[0];

  const metadata = latestMetadata
    ? JSON.parse(latestMetadata.content)
    : JSON.parse(createEvent.content);

  return {
    id: channelId,
    creator: createEvent.pubkey,
    createdAt: createEvent.created_at,
    ...metadata,
  };
}

// 查詢頻道訊息
async function getChannelMessages(
  channelId: string,
  limit: number = 50,
  since?: number
) {
  return await pool.querySync(relays, {
    kinds: [42],
    '#e': [channelId],
    limit,
    since,
  });
}

// 訂閱頻道即時訊息
function subscribeToChannel(
  channelId: string,
  onMessage: (event: Event) => void
) {
  return pool.subscribeMany(
    relays,
    [{ kinds: [42], '#e': [channelId] }],
    {
      onevent: onMessage,
    }
  );
}`}

過濾隱藏內容

{`// 取得用戶的隱藏和靜音列表
async function getUserFilters(userPubkey: string) {
  const [hideEvents, muteEvents] = await Promise.all([
    pool.querySync(relays, {
      kinds: [43],
      authors: [userPubkey],
    }),
    pool.querySync(relays, {
      kinds: [44],
      authors: [userPubkey],
    }),
  ]);

  // 提取隱藏的訊息 ID
  const hiddenMessages = new Set(
    hideEvents.flatMap(e =>
      e.tags.filter(t => t[0] === 'e').map(t => t[1])
    )
  );

  // 提取靜音的用戶
  const mutedUsers = new Set(
    muteEvents.flatMap(e =>
      e.tags.filter(t => t[0] === 'p').map(t => t[1])
    )
  );

  return { hiddenMessages, mutedUsers };
}

// 過濾訊息
function filterMessages(
  messages: Event[],
  filters: { hiddenMessages: Set; mutedUsers: Set }
) {
  return messages.filter(msg =>
    !filters.hiddenMessages.has(msg.id) &&
    !filters.mutedUsers.has(msg.pubkey)
  );
}`}

訊息串結構

{`interface ThreadedMessage {
  event: Event;
  replies: ThreadedMessage[];
}

// 建構訊息串
function buildMessageThreads(messages: Event[]): ThreadedMessage[] {
  const messageMap = new Map();
  const rootMessages: ThreadedMessage[] = [];

  // 建立所有訊息的映射
  for (const msg of messages) {
    messageMap.set(msg.id, { event: msg, replies: [] });
  }

  // 建立父子關係
  for (const msg of messages) {
    const threaded = messageMap.get(msg.id)!;
    const replyTag = msg.tags.find(t => t[0] === 'e' && t[3] === 'reply');

    if (replyTag) {
      const parentId = replyTag[1];
      const parent = messageMap.get(parentId);
      if (parent) {
        parent.replies.push(threaded);
      } else {
        // 父訊息不存在,視為根訊息
        rootMessages.push(threaded);
      }
    } else {
      // 沒有 reply 標籤,是根訊息
      rootMessages.push(threaded);
    }
  }

  // 按時間排序
  const sortByTime = (a: ThreadedMessage, b: ThreadedMessage) =>
    a.event.created_at - b.event.created_at;

  rootMessages.sort(sortByTime);
  for (const msg of messageMap.values()) {
    msg.replies.sort(sortByTime);
  }

  return rootMessages;
}`}

最佳實踐

  • 頻道發現:使用 t 標籤添加分類,方便搜尋
  • 中繼器選擇:在頻道中繼資料中建議特定中繼器
  • 訊息限制:載入訊息時設定合理的 limit
  • 本地快取:快取隱藏和靜音列表以提升效能
  • 驗證創建者:只接受頻道創建者的 kind 41 更新

注意事項

  • 完全公開:所有訊息都是公開的,沒有隱私保護
  • 無刪除:訊息一旦發布就無法真正刪除
  • 客戶端過濾:隱藏和靜音只在客戶端生效
  • 無權限系統:任何人都可以發言,沒有管理員概念

私密群組

如果需要私密群組功能,請參考 NIP-29(基於中繼器的群組)或 NIP-17(私密訊息),它們提供更好的隱私保護。

  • NIP-01 - 基本協議與事件結構
  • NIP-10 - 回覆與標記(訊息串結構)
  • NIP-17 - 私密訊息
  • NIP-30 - 自訂表情(可用於頻道訊息)

參考資源

已複製連結
已複製到剪貼簿