高級
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
參考資源
已複製連結