跳至主要內容

NIP-48: 代理標籤

標記從其他協議橋接到 Nostr 的內容來源

概述

NIP-48 定義了 proxy 標籤,用於標記從其他協議(如 ActivityPub、AT Protocol、RSS) 橋接到 Nostr 的內容。這讓客戶端可以識別重複內容、顯示原始來源連結, 或在去重時優先處理原生 Nostr 內容。

標籤格式

["proxy", "<id>", "<protocol>"]
欄位 說明
id 原始內容的唯一識別符,格式因協議而異
protocol 來源協議名稱

支援的協議

協議 ID 格式 範例
activitypub ActivityPub 物件 URL https://mastodon.social/users/alice/statuses/123
atproto AT URI at://did:plc:xxx/app.bsky.feed.post/abc
rss RSS Feed URL + guid https://blog.example.com/feed.xml#post-123
web 網頁 URL https://twitter.com/jack/status/20

範例

從 ActivityPub 橋接

{
  "kind": 1,
  "content": "這是從 Mastodon 橋接過來的貼文!",
  "tags": [
    ["proxy", "https://mastodon.social/users/alice/statuses/123456789", "activitypub"]
  ]
}

從 Bluesky (AT Protocol) 橋接

{
  "kind": 1,
  "content": "這是從 Bluesky 橋接過來的貼文!",
  "tags": [
    ["proxy", "at://did:plc:abc123/app.bsky.feed.post/xyz789", "atproto"]
  ]
}

從 RSS 橋接

{
  "kind": 1,
  "content": "這是部落格的新文章摘要...",
  "tags": [
    ["proxy", "https://blog.example.com/feed.xml#article-2024-01-15", "rss"]
  ]
}

從 Twitter/X 橋接

{
  "kind": 1,
  "content": "這是從 Twitter 橋接過來的推文!",
  "tags": [
    ["proxy", "https://twitter.com/jack/status/20", "web"]
  ]
}

TypeScript 實作

建立代理事件

import { finalizeEvent } from 'nostr-tools';

type ProxyProtocol = 'activitypub' | 'atproto' | 'rss' | 'web';

interface ProxySource {
  id: string;
  protocol: ProxyProtocol;
}

function createProxiedEvent(
  content: string,
  source: ProxySource,
  secretKey: Uint8Array,
  additionalTags: string[][] = []
) {
  const tags: string[][] = [
    ['proxy', source.id, source.protocol],
    ...additionalTags,
  ];

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

  return finalizeEvent(event, secretKey);
}

// 使用範例:橋接 Mastodon 貼文
const mastodonPost = createProxiedEvent(
  '這是一則從 Mastodon 橋接的貼文!',
  {
    id: 'https://mastodon.social/users/alice/statuses/123456789',
    protocol: 'activitypub',
  },
  secretKey
);

檢測代理事件

interface ProxyInfo {
  id: string;
  protocol: string;
  isProxied: boolean;
}

function getProxyInfo(event: any): ProxyInfo | null {
  const proxyTag = event.tags.find((t: string[]) => t[0] === 'proxy');

  if (!proxyTag || proxyTag.length < 3) {
    return null;
  }

  return {
    id: proxyTag[1],
    protocol: proxyTag[2],
    isProxied: true,
  };
}

function isProxiedEvent(event: any): boolean {
  return event.tags.some((t: string[]) => t[0] === 'proxy');
}

// 使用範例
const proxyInfo = getProxyInfo(event);
if (proxyInfo) {
  console.log(`來源協議: ${proxyInfo.protocol}`);
  console.log(`原始 ID: ${proxyInfo.id}`);
}

去重處理

interface DeduplicatedEvent {
  event: any;
  isNative: boolean;
  proxySource?: ProxyInfo;
}

function deduplicateEvents(events: any[]): DeduplicatedEvent[] {
  const proxyMap = new Map<string, any>();
  const nativeEvents: any[] = [];

  // 分類事件
  events.forEach((event) => {
    const proxyInfo = getProxyInfo(event);
    if (proxyInfo) {
      // 記錄代理事件,以原始 ID 為鍵
      const existing = proxyMap.get(proxyInfo.id);
      if (!existing || event.created_at > existing.created_at) {
        proxyMap.set(proxyInfo.id, event);
      }
    } else {
      nativeEvents.push(event);
    }
  });

  // 合併結果,優先顯示原生事件
  const result: DeduplicatedEvent[] = [];

  nativeEvents.forEach((event) => {
    result.push({ event, isNative: true });
  });

  proxyMap.forEach((event) => {
    const proxyInfo = getProxyInfo(event)!;
    result.push({
      event,
      isNative: false,
      proxySource: proxyInfo,
    });
  });

  return result.sort((a, b) => b.event.created_at - a.event.created_at);
}

顯示來源連結

function getSourceUrl(proxyInfo: ProxyInfo): string | null {
  switch (proxyInfo.protocol) {
    case 'activitypub':
    case 'web':
      // 直接使用 URL
      return proxyInfo.id;

    case 'atproto':
      // 將 AT URI 轉換為 Bluesky 網頁連結
      const match = proxyInfo.id.match(
        /at:\/\/(did:[^\/]+)\/app\.bsky\.feed\.post\/(.+)/
      );
      if (match) {
        return `https://bsky.app/profile/${match[1]}/post/${match[2]}`;
      }
      return null;

    case 'rss':
      // 移除 fragment,返回 feed URL
      return proxyInfo.id.split('#')[0];

    default:
      return null;
  }
}

// React 元件範例
function ProxyBadge({ event }: { event: any }) {
  const proxyInfo = getProxyInfo(event);

  if (!proxyInfo) {
    return null;
  }

  const sourceUrl = getSourceUrl(proxyInfo);
  const protocolLabels: Record<string, string> = {
    activitypub: 'Fediverse',
    atproto: 'Bluesky',
    rss: 'RSS',
    web: 'Web',
  };

  return (
    <div className="proxy-badge">
      <span>來自 {protocolLabels[proxyInfo.protocol] || proxyInfo.protocol}</span>
      {sourceUrl && (
        <a href={sourceUrl} target="_blank" rel="noopener noreferrer">
          查看原文
        </a>
      )}
    </div>
  );
}

使用場景

跨平台橋接

  • 將 Mastodon/Fediverse 內容同步到 Nostr
  • 將 Bluesky 貼文橋接到 Nostr
  • 將部落格 RSS 更新發布到 Nostr
  • 歸檔 Twitter 內容到 Nostr

內容去重

  • 識別從多個來源橋接的相同內容
  • 優先顯示原生 Nostr 內容
  • 合併來自不同橋接服務的重複貼文

來源追蹤

  • 顯示內容的原始來源平台
  • 提供返回原始內容的連結
  • 讓用戶了解內容的真實來源

最佳實踐

  • 使用唯一識別符:確保 ID 在該協議中是全局唯一的
  • 保持連結有效:原始來源 URL 應該是可訪問的
  • 標記所有橋接內容:讓用戶和客戶端能識別非原生內容
  • 處理來源失效:原始內容可能被刪除,優雅處理
  • NIP-01:基本協議 - 事件格式
  • NIP-73:外部內容 ID - 另一種外部引用方式

參考資源

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