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