claude-to-nostr.user.js raw

   1  // ==UserScript==
   2  // @name        Claude to Nostr
   3  // @namespace   mleku.dev
   4  // @version     0.2
   5  // @description Broadcast Claude conversations to Nostr on command
   6  // @match       https://claude.ai/*
   7  // @grant       none
   8  // ==/UserScript==
   9  
  10  (function () {
  11    'use strict';
  12  
  13    const RELAYS = [
  14      'wss://relay.damus.io',
  15      'wss://nos.lol',
  16      'wss://relay.nostr.band',
  17    ];
  18  
  19    const published = new Set();
  20    let sockets = {};
  21    let threadRoot = null;
  22    let lastEventId = null;
  23  
  24    function connectRelays() {
  25      for (const url of RELAYS) {
  26        if (sockets[url] && sockets[url].readyState === WebSocket.OPEN) continue;
  27        const ws = new WebSocket(url);
  28        ws.onopen = () => console.log(`[nostr] connected: ${url}`);
  29        ws.onclose = () => {
  30          console.log(`[nostr] disconnected: ${url}, reconnecting in 5s`);
  31          setTimeout(() => connectRelays(), 5000);
  32        };
  33        ws.onerror = (e) => console.error(`[nostr] error on ${url}:`, e);
  34        sockets[url] = ws;
  35      }
  36    }
  37  
  38    function publishToRelays(event) {
  39      const msg = JSON.stringify(['EVENT', event]);
  40      for (const url of RELAYS) {
  41        const ws = sockets[url];
  42        if (ws && ws.readyState === WebSocket.OPEN) {
  43          ws.send(msg);
  44          console.log(`[nostr] published ${event.id.slice(0, 8)}... to ${url}`);
  45        }
  46      }
  47    }
  48  
  49    function formatClaudeResponse(text) {
  50      return text
  51        .split('\n')
  52        .map((line) => {
  53          if (line.trim() === '') return '>';
  54          return `> ${line}`;
  55        })
  56        .join('\n');
  57    }
  58  
  59    async function signAndPublish(content, isReply) {
  60      if (!window.nostr) {
  61        console.error('[nostr] NIP-07 extension not found (install nos2x or similar)');
  62        showNotification('No NIP-07 extension found', 'error');
  63        return null;
  64      }
  65  
  66      const tags = [];
  67  
  68      if (isReply && threadRoot) {
  69        tags.push(['e', threadRoot, '', 'root']);
  70      }
  71      if (isReply && lastEventId && lastEventId !== threadRoot) {
  72        tags.push(['e', lastEventId, '', 'reply']);
  73      }
  74  
  75      const event = {
  76        kind: 1,
  77        created_at: Math.floor(Date.now() / 1000),
  78        tags: tags,
  79        content: content,
  80      };
  81  
  82      try {
  83        const signed = await window.nostr.signEvent(event);
  84        publishToRelays(signed);
  85  
  86        if (!threadRoot) {
  87          threadRoot = signed.id;
  88        }
  89        lastEventId = signed.id;
  90  
  91        return signed;
  92      } catch (e) {
  93        console.error('[nostr] signing failed:', e);
  94        showNotification('Signing failed', 'error');
  95        return null;
  96      }
  97    }
  98  
  99    function showNotification(text, type = 'info') {
 100      const el = document.createElement('div');
 101      el.textContent = text;
 102      el.style.cssText = `
 103        position: fixed; bottom: 20px; right: 20px; z-index: 99999;
 104        padding: 8px 16px; border-radius: 6px; font-size: 13px;
 105        font-family: monospace; color: white; opacity: 0.9;
 106        background: ${type === 'error' ? '#c0392b' : '#2c3e50'};
 107        transition: opacity 0.5s;
 108      `;
 109      document.body.appendChild(el);
 110      setTimeout(() => {
 111        el.style.opacity = '0';
 112        setTimeout(() => el.remove(), 500);
 113      }, 3000);
 114    }
 115  
 116    function extractMessages() {
 117      const messages = [];
 118  
 119      const turns = document.querySelectorAll(
 120        '[data-testid^="user-turn-"], [data-testid^="assistant-turn-"]'
 121      );
 122  
 123      if (turns.length === 0) {
 124        const allMessages = document.querySelectorAll(
 125          '.font-user-message, .font-claude-message'
 126        );
 127        allMessages.forEach((el) => {
 128          const isHuman = el.classList.contains('font-user-message');
 129          messages.push({
 130            role: isHuman ? 'human' : 'assistant',
 131            text: el.innerText.trim(),
 132            el: el,
 133          });
 134        });
 135      } else {
 136        turns.forEach((turn) => {
 137          const isHuman = turn.getAttribute('data-testid')?.startsWith('user-turn');
 138          messages.push({
 139            role: isHuman ? 'human' : 'assistant',
 140            text: turn.innerText.trim(),
 141            el: turn,
 142          });
 143        });
 144      }
 145  
 146      return messages;
 147    }
 148  
 149    function fingerprint(role, text) {
 150      return `${role}:${text.slice(0, 200)}`;
 151    }
 152  
 153    async function publishMessages(messages) {
 154      let count = 0;
 155      for (const msg of messages) {
 156        const fp = fingerprint(msg.role, msg.text);
 157        if (published.has(fp)) continue;
 158        if (!msg.text || msg.text.length < 2) continue;
 159  
 160        let content;
 161        if (msg.role === 'human') {
 162          content = msg.text;
 163        } else {
 164          content = formatClaudeResponse(msg.text);
 165        }
 166  
 167        const isReply = published.size > 0;
 168        const signed = await signAndPublish(content, isReply);
 169  
 170        if (signed) {
 171          published.add(fp);
 172          count++;
 173          showNotification(`⚡ ${msg.role} → nostr (${signed.id.slice(0, 8)}...)`);
 174        }
 175      }
 176      return count;
 177    }
 178  
 179    // broadcast last prompt/response pair only
 180    async function broadcastLast() {
 181      const messages = extractMessages();
 182      if (messages.length === 0) {
 183        showNotification('No messages to broadcast', 'error');
 184        return;
 185      }
 186  
 187      // find the last unpublished human+assistant pair
 188      // walk backward to find the last assistant message, then its preceding human message
 189      const last = [];
 190      let i = messages.length - 1;
 191  
 192      // find last assistant message
 193      while (i >= 0 && messages[i].role !== 'assistant') i--;
 194      if (i >= 0) last.unshift(messages[i]);
 195  
 196      // find the human message preceding it
 197      const assistantIdx = i;
 198      i--;
 199      while (i >= 0 && messages[i].role !== 'human') i--;
 200      if (i >= 0) last.unshift(messages[i]);
 201  
 202      if (last.length === 0) {
 203        showNotification('No new messages to broadcast', 'error');
 204        return;
 205      }
 206  
 207      const count = await publishMessages(last);
 208      if (count > 0) {
 209        showNotification(`⚡ broadcast ${count} message(s)`);
 210      } else {
 211        showNotification('Messages already broadcast', 'error');
 212      }
 213    }
 214  
 215    // broadcast entire conversation
 216    async function broadcastAll() {
 217      const messages = extractMessages();
 218      if (messages.length === 0) {
 219        showNotification('No messages to broadcast', 'error');
 220        return;
 221      }
 222  
 223      const count = await publishMessages(messages);
 224      if (count > 0) {
 225        showNotification(`⚡ broadcast all: ${count} message(s)`);
 226      } else {
 227        showNotification('All messages already broadcast', 'error');
 228      }
 229    }
 230  
 231    // intercept the input field to detect "broadcast" and "broadcast all" commands
 232    function hookInput() {
 233      // watch for form submissions on claude.ai
 234      document.addEventListener(
 235        'keydown',
 236        (e) => {
 237          if (e.key !== 'Enter' || e.shiftKey) return;
 238  
 239          // find the active input/textarea
 240          const input =
 241            document.activeElement?.closest('[contenteditable]') ||
 242            document.activeElement;
 243          if (!input) return;
 244  
 245          const text = (input.innerText || input.value || '').trim().toLowerCase();
 246  
 247          if (text === 'broadcast all') {
 248            e.preventDefault();
 249            e.stopPropagation();
 250            // clear the input
 251            if (input.innerText !== undefined) {
 252              input.innerText = '';
 253            } else {
 254              input.value = '';
 255            }
 256            // trigger input event to reset the UI
 257            input.dispatchEvent(new Event('input', { bubbles: true }));
 258            console.log('[nostr] broadcast all triggered');
 259            broadcastAll();
 260            return;
 261          }
 262  
 263          if (text === 'broadcast') {
 264            e.preventDefault();
 265            e.stopPropagation();
 266            if (input.innerText !== undefined) {
 267              input.innerText = '';
 268            } else {
 269              input.value = '';
 270            }
 271            input.dispatchEvent(new Event('input', { bubbles: true }));
 272            console.log('[nostr] broadcast last triggered');
 273            broadcastLast();
 274            return;
 275          }
 276        },
 277        true // capture phase to intercept before claude.ai handles it
 278      );
 279    }
 280  
 281    // keyboard shortcut: ctrl+shift+N broadcasts last, ctrl+shift+A broadcasts all
 282    document.addEventListener('keydown', (e) => {
 283      if (e.ctrlKey && e.shiftKey && e.key === 'N') {
 284        e.preventDefault();
 285        console.log('[nostr] broadcast last (shortcut)');
 286        broadcastLast();
 287      }
 288      if (e.ctrlKey && e.shiftKey && e.key === 'A') {
 289        e.preventDefault();
 290        console.log('[nostr] broadcast all (shortcut)');
 291        broadcastAll();
 292      }
 293    });
 294  
 295    // reset thread when navigating to a new conversation
 296    let lastPath = location.pathname;
 297    setInterval(() => {
 298      if (location.pathname !== lastPath) {
 299        lastPath = location.pathname;
 300        threadRoot = null;
 301        lastEventId = null;
 302        published.clear();
 303        console.log('[nostr] new conversation detected, thread reset');
 304      }
 305    }, 1000);
 306  
 307    // init
 308    connectRelays();
 309    setTimeout(hookInput, 3000);
 310  
 311    console.log(
 312      '[nostr] claude-to-nostr v0.2 loaded. Type "broadcast" or "broadcast all" in the input. Shortcuts: ctrl+shift+N (last), ctrl+shift+A (all).'
 313    );
 314  })();
 315