跳至主要內容
高級

NIP-29 中繼器群組

Nostr 基於中繼器的群組標準,實現私密或封閉的群組聊天功能。

15 分鐘

概述

NIP-29 定義了基於中繼器的群組標準,與 NIP-28 的公開頻道不同, NIP-29 群組可以限制寫入權限給特定成員,並可選擇性地設為私密(只有成員可讀)。 群組由中繼器管理,提供完整的成員管理和權限控制。

中繼器託管

NIP-29 群組依賴特定中繼器來執行權限控制。中繼器負責驗證成員身份、 處理管理操作,並簽署群組中繼資料。

群組識別

群組使用 <host>'<group-id> 格式識別:

{`groups.nostr.com'bitcoin-dev
relay.example.com'my-group-123
nos.lol'_`}
  • host - 託管中繼器的域名
  • group-id - 群組 ID,只能包含 a-z0-9-_
  • _ - 特殊 ID,表示中繼器的預設討論區

事件類型

管理事件 (9000-9020)

Kind 動作 說明
9000 put-user 添加用戶或更新角色
9001 remove-user 移除用戶
9002 edit-metadata 編輯群組資訊
9005 delete-event 刪除事件
9007 create-group 建立群組
9008 delete-group 刪除群組
9009 create-invite 建立邀請碼

用戶事件 (9021-9022)

Kind 動作 說明
9021 join-request 請求加入群組
9022 leave-request 請求離開群組

群組中繼資料 (39000-39003)

Kind 內容 簽名者
39000 群組資訊 中繼器
39001 管理員列表 中繼器
39002 成員列表 中繼器
39003 角色定義 中繼器

存取控制

群組可以設定不同的存取級別:

標籤 效果
private 只有成員可以讀取訊息
closed 忽略加入請求(需要邀請碼)
restricted 只有成員可以寫入
hidden 非成員無法看到群組資訊

標籤要求

所有群組內容都需要 h 標籤標識群組:

{`{
  "kind": 9,
  "content": "群組訊息內容",
  "tags": [
    ["h", ""],
    ["previous", "", ""]
  ],
  ...
}`}

previous 標籤引用前 50 個事件中的事件 ID 前綴(最多 8 字元), 用於建立時間線順序,防止延遲發布攻擊。

群組中繼資料 (Kind 39000)

{`{
  "kind": 39000,
  "pubkey": "",
  "content": "",
  "tags": [
    ["d", ""],
    ["name", "Bitcoin 開發者"],
    ["picture", "https://example.com/group.png"],
    ["about", "討論比特幣開發相關話題"],
    ["private"],
    ["closed"]
  ],
  ...
}`}

管理員列表 (Kind 39001)

{`{
  "kind": 39001,
  "pubkey": "",
  "content": "",
  "tags": [
    ["d", ""],
    ["p", "", "owner"],
    ["p", "", "moderator"],
    ["p", "", "moderator"]
  ],
  ...
}`}

實作

請求加入群組

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

// 請求加入群組
function requestJoinGroup(
  privateKey: Uint8Array,
  groupId: string,
  reason?: string,
  inviteCode?: string
) {
  const tags: string[][] = [['h', groupId]];

  if (inviteCode) {
    tags.push(['code', inviteCode]);
  }

  return finalizeEvent({
    kind: 9021,
    created_at: Math.floor(Date.now() / 1000),
    tags,
    content: reason || '',
  }, privateKey);
}

// 請求離開群組
function requestLeaveGroup(
  privateKey: Uint8Array,
  groupId: string
) {
  return finalizeEvent({
    kind: 9022,
    created_at: Math.floor(Date.now() / 1000),
    tags: [['h', groupId]],
    content: '',
  }, privateKey);
}`}

發送群組訊息

{`// 發送群組訊息
function sendGroupMessage(
  privateKey: Uint8Array,
  groupId: string,
  content: string,
  previousEvents: string[] = []
) {
  const tags: string[][] = [['h', groupId]];

  // 添加 previous 標籤(取前 8 字元)
  if (previousEvents.length > 0) {
    const prefixes = previousEvents
      .slice(0, 5)
      .map(id => id.slice(0, 8));
    tags.push(['previous', ...prefixes]);
  }

  return finalizeEvent({
    kind: 9, // 群組聊天訊息
    created_at: Math.floor(Date.now() / 1000),
    tags,
    content,
  }, privateKey);
}`}

管理操作(需要管理員權限)

{`// 添加用戶到群組
function addUserToGroup(
  adminPrivateKey: Uint8Array,
  groupId: string,
  userPubkey: string,
  roles: string[] = []
) {
  const tags: string[][] = [
    ['h', groupId],
    ['p', userPubkey, ...roles],
  ];

  return finalizeEvent({
    kind: 9000, // put-user
    created_at: Math.floor(Date.now() / 1000),
    tags,
    content: '',
  }, adminPrivateKey);
}

// 移除用戶
function removeUserFromGroup(
  adminPrivateKey: Uint8Array,
  groupId: string,
  userPubkey: string
) {
  return finalizeEvent({
    kind: 9001, // remove-user
    created_at: Math.floor(Date.now() / 1000),
    tags: [
      ['h', groupId],
      ['p', userPubkey],
    ],
    content: '',
  }, adminPrivateKey);
}

// 更新群組資訊
function editGroupMetadata(
  adminPrivateKey: Uint8Array,
  groupId: string,
  metadata: {
    name?: string;
    about?: string;
    picture?: string;
    private?: boolean;
    closed?: boolean;
  }
) {
  const tags: string[][] = [['h', groupId]];

  if (metadata.name) tags.push(['name', metadata.name]);
  if (metadata.about) tags.push(['about', metadata.about]);
  if (metadata.picture) tags.push(['picture', metadata.picture]);
  if (metadata.private) tags.push(['private']);
  if (metadata.closed) tags.push(['closed']);

  return finalizeEvent({
    kind: 9002, // edit-metadata
    created_at: Math.floor(Date.now() / 1000),
    tags,
    content: '',
  }, adminPrivateKey);
}

// 刪除訊息
function deleteGroupMessage(
  adminPrivateKey: Uint8Array,
  groupId: string,
  eventId: string
) {
  return finalizeEvent({
    kind: 9005, // delete-event
    created_at: Math.floor(Date.now() / 1000),
    tags: [
      ['h', groupId],
      ['e', eventId],
    ],
    content: '',
  }, adminPrivateKey);
}

// 建立邀請碼
function createInvite(
  adminPrivateKey: Uint8Array,
  groupId: string
) {
  return finalizeEvent({
    kind: 9009, // create-invite
    created_at: Math.floor(Date.now() / 1000),
    tags: [['h', groupId]],
    content: '',
  }, adminPrivateKey);
}`}

查詢群組

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

// 取得群組資訊
async function getGroupInfo(
  pool: SimplePool,
  relayUrl: string,
  groupId: string
) {
  const [metadata, admins, members] = await Promise.all([
    pool.get([relayUrl], {
      kinds: [39000],
      '#d': [groupId],
    }),
    pool.get([relayUrl], {
      kinds: [39001],
      '#d': [groupId],
    }),
    pool.get([relayUrl], {
      kinds: [39002],
      '#d': [groupId],
    }),
  ]);

  if (!metadata) return null;

  // 解析群組資訊
  const getTag = (event: Event, name: string) =>
    event.tags.find(t => t[0] === name)?.[1];

  const hasTag = (event: Event, name: string) =>
    event.tags.some(t => t[0] === name);

  return {
    id: groupId,
    name: getTag(metadata, 'name'),
    about: getTag(metadata, 'about'),
    picture: getTag(metadata, 'picture'),
    isPrivate: hasTag(metadata, 'private'),
    isClosed: hasTag(metadata, 'closed'),
    isRestricted: hasTag(metadata, 'restricted'),
    isHidden: hasTag(metadata, 'hidden'),
    admins: admins?.tags
      .filter(t => t[0] === 'p')
      .map(t => ({ pubkey: t[1], role: t[2] })) || [],
    members: members?.tags
      .filter(t => t[0] === 'p')
      .map(t => t[1]) || [],
  };
}

// 取得群組訊息
async function getGroupMessages(
  pool: SimplePool,
  relayUrl: string,
  groupId: string,
  limit: number = 50
) {
  return await pool.querySync([relayUrl], {
    kinds: [9], // 群組聊天訊息
    '#h': [groupId],
    limit,
  });
}

// 訂閱群組即時訊息
function subscribeToGroup(
  pool: SimplePool,
  relayUrl: string,
  groupId: string,
  onMessage: (event: Event) => void
) {
  return pool.subscribeMany(
    [relayUrl],
    [{ kinds: [9], '#h': [groupId] }],
    { onevent: onMessage }
  );
}`}

檢查成員身份

{`// 檢查用戶是否為群組成員
async function checkMembership(
  pool: SimplePool,
  relayUrl: string,
  groupId: string,
  userPubkey: string
): Promise<{ isMember: boolean; isAdmin: boolean; roles: string[] }> {
  // 檢查管理員列表
  const admins = await pool.get([relayUrl], {
    kinds: [39001],
    '#d': [groupId],
  });

  if (admins) {
    const adminTag = admins.tags.find(
      t => t[0] === 'p' && t[1] === userPubkey
    );
    if (adminTag) {
      return {
        isMember: true,
        isAdmin: true,
        roles: adminTag.slice(2),
      };
    }
  }

  // 檢查成員列表
  const members = await pool.get([relayUrl], {
    kinds: [39002],
    '#d': [groupId],
  });

  if (members) {
    const memberTag = members.tags.find(
      t => t[0] === 'p' && t[1] === userPubkey
    );
    if (memberTag) {
      return {
        isMember: true,
        isAdmin: false,
        roles: [],
      };
    }
  }

  // 檢查最近的 put-user 事件
  const putUserEvents = await pool.querySync([relayUrl], {
    kinds: [9000],
    '#h': [groupId],
    '#p': [userPubkey],
    limit: 1,
  });

  if (putUserEvents.length > 0) {
    const roles = putUserEvents[0].tags
      .find(t => t[0] === 'p' && t[1] === userPubkey)
      ?.slice(2) || [];

    return {
      isMember: true,
      isAdmin: roles.includes('admin') || roles.includes('owner'),
      roles,
    };
  }

  return { isMember: false, isAdmin: false, roles: [] };
}`}

非託管群組

非託管群組可以在任何支援 NIP-29 的中繼器上使用,不需要特殊設定。 所有用戶都被視為成員,依賴中繼器的一般審核機制。

{`// 在任何中繼器上使用非託管群組
const unmanagedGroupId = 'bitcoin-chat';

// 直接發送訊息(無需加入)
const message = sendGroupMessage(
  privateKey,
  unmanagedGroupId,
  '大家好!'
);

// 發布到任何中繼器
await pool.publish(relays, message);`}

中繼器職責

  • 簽署中繼資料:群組資訊由中繼器簽名
  • 驗證權限:檢查管理操作的授權
  • 執行存取控制:根據群組設定限制讀寫
  • 驗證 previous 標籤:防止延遲發布攻擊
  • 處理加入/離開請求:管理成員資格

NIP-29 vs NIP-28

特性 NIP-28 NIP-29
存取控制 完全公開 可設定私密/封閉
成員管理 完整成員/角色系統
管理員 僅創建者 多層級角色
中繼器依賴 任意中繼器 需支援 NIP-29
邀請系統
  • NIP-01 - 基本協議與事件結構
  • NIP-28 - 公開聊天(無權限控制)
  • NIP-17 - 私密訊息
  • NIP-42 - 中繼器認證

參考資源

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