Coverage for integrations / channels / extensions / nostr_adapter.py: 27.7%
495 statements
« prev ^ index » next coverage.py v7.14.0, created at 2026-05-12 04:49 +0000
« prev ^ index » next coverage.py v7.14.0, created at 2026-05-12 04:49 +0000
1"""
2Nostr Protocol Channel Adapter
4Implements Nostr decentralized messaging protocol.
5Based on HevolveBot extension patterns for decentralized networks.
7Features:
8- NIP-01: Basic protocol support
9- NIP-04: Encrypted DMs
10- NIP-05: DNS-based verification
11- NIP-19: bech32-encoded entities
12- NIP-42: Authentication
13- Multi-relay support
14- Event signing with secp256k1
15- Subscription management
16- Reconnection with exponential backoff
17- Relay pool management
18"""
20from __future__ import annotations
22import asyncio
23import logging
24import os
25import json
26import time
27import hashlib
28import secrets
29from typing import Optional, List, Dict, Any, Callable, Set, Tuple
30from datetime import datetime
31from dataclasses import dataclass, field
32from enum import IntEnum
34try:
35 import websockets
36 from websockets.exceptions import ConnectionClosed
37 HAS_WEBSOCKETS = True
38except ImportError:
39 HAS_WEBSOCKETS = False
41try:
42 from secp256k1 import PrivateKey, PublicKey
43 HAS_SECP256K1 = True
44except ImportError:
45 HAS_SECP256K1 = False
47try:
48 from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
49 from cryptography.hazmat.backends import default_backend
50 HAS_CRYPTO = True
51except ImportError:
52 HAS_CRYPTO = False
54from ..base import (
55 ChannelAdapter,
56 ChannelConfig,
57 ChannelStatus,
58 Message,
59 MessageType,
60 MediaAttachment,
61 SendResult,
62 ChannelConnectionError,
63 ChannelSendError,
64 ChannelRateLimitError,
65)
67logger = logging.getLogger(__name__)
70class NostrEventKind(IntEnum):
71 """Nostr event kinds (NIP-01)."""
72 SET_METADATA = 0
73 TEXT_NOTE = 1
74 RECOMMEND_RELAY = 2
75 CONTACTS = 3
76 ENCRYPTED_DM = 4
77 DELETE = 5
78 REPOST = 6
79 REACTION = 7
80 BADGE_AWARD = 8
81 CHANNEL_CREATE = 40
82 CHANNEL_METADATA = 41
83 CHANNEL_MESSAGE = 42
84 CHANNEL_HIDE_MESSAGE = 43
85 CHANNEL_MUTE_USER = 44
86 AUTH = 22242
89@dataclass
90class NostrConfig(ChannelConfig):
91 """Nostr-specific configuration."""
92 private_key: str = "" # hex or nsec format
93 relays: List[str] = field(default_factory=lambda: [
94 "wss://relay.damus.io",
95 "wss://nos.lol",
96 "wss://relay.snort.social",
97 ])
98 nip05_identifier: str = "" # user@domain.com
99 enable_nip04_encryption: bool = True
100 enable_nip42_auth: bool = True
101 subscription_limit: int = 100
102 reconnect_attempts: int = 5
103 reconnect_delay: float = 1.0
104 message_expiry: int = 0 # 0 = no expiry
107@dataclass
108class NostrEvent:
109 """Nostr event structure (NIP-01)."""
110 id: str
111 pubkey: str
112 created_at: int
113 kind: int
114 tags: List[List[str]]
115 content: str
116 sig: str
118 def to_dict(self) -> Dict[str, Any]:
119 """Convert to dictionary."""
120 return {
121 "id": self.id,
122 "pubkey": self.pubkey,
123 "created_at": self.created_at,
124 "kind": self.kind,
125 "tags": self.tags,
126 "content": self.content,
127 "sig": self.sig,
128 }
130 @classmethod
131 def from_dict(cls, data: Dict[str, Any]) -> 'NostrEvent':
132 """Create from dictionary."""
133 return cls(
134 id=data["id"],
135 pubkey=data["pubkey"],
136 created_at=data["created_at"],
137 kind=data["kind"],
138 tags=data.get("tags", []),
139 content=data["content"],
140 sig=data["sig"],
141 )
144@dataclass
145class NostrFilter:
146 """Nostr subscription filter."""
147 ids: Optional[List[str]] = None
148 authors: Optional[List[str]] = None
149 kinds: Optional[List[int]] = None
150 since: Optional[int] = None
151 until: Optional[int] = None
152 limit: Optional[int] = None
153 tags: Dict[str, List[str]] = field(default_factory=dict)
155 def to_dict(self) -> Dict[str, Any]:
156 """Convert to dictionary for subscription."""
157 result = {}
158 if self.ids:
159 result["ids"] = self.ids
160 if self.authors:
161 result["authors"] = self.authors
162 if self.kinds:
163 result["kinds"] = self.kinds
164 if self.since:
165 result["since"] = self.since
166 if self.until:
167 result["until"] = self.until
168 if self.limit:
169 result["limit"] = self.limit
170 for key, values in self.tags.items():
171 result[f"#{key}"] = values
172 return result
175@dataclass
176class RelayConnection:
177 """Relay connection state."""
178 url: str
179 ws: Optional[websockets.WebSocketClientProtocol] = None
180 connected: bool = False
181 subscriptions: Set[str] = field(default_factory=set)
182 reconnect_count: int = 0
185class NostrAdapter(ChannelAdapter):
186 """
187 Nostr protocol adapter with multi-relay support.
189 Usage:
190 config = NostrConfig(
191 private_key="your-private-key-hex",
192 relays=["wss://relay.damus.io", "wss://nos.lol"],
193 )
194 adapter = NostrAdapter(config)
195 adapter.on_message(my_handler)
196 await adapter.start()
197 """
199 def __init__(self, config: NostrConfig):
200 if not HAS_WEBSOCKETS:
201 raise ImportError(
202 "websockets not installed. "
203 "Install with: pip install websockets"
204 )
206 super().__init__(config)
207 self.nostr_config: NostrConfig = config
208 self._private_key: Optional[bytes] = None
209 self._public_key: Optional[bytes] = None
210 self._pubkey_hex: str = ""
211 self._relays: Dict[str, RelayConnection] = {}
212 self._subscriptions: Dict[str, NostrFilter] = {}
213 self._read_tasks: List[asyncio.Task] = []
214 self._event_handlers: Dict[int, List[Callable]] = {}
215 self._seen_events: Set[str] = set()
216 self._pending_events: Dict[str, asyncio.Event] = {}
218 @property
219 def name(self) -> str:
220 return "nostr"
222 async def connect(self) -> bool:
223 """Connect to Nostr relays."""
224 if not self.nostr_config.private_key:
225 logger.error("Nostr private key required")
226 return False
228 try:
229 # Parse private key
230 self._parse_private_key()
232 if not self._private_key:
233 logger.error("Failed to parse private key")
234 return False
236 # Derive public key
237 self._derive_public_key()
239 # Connect to relays
240 connected_count = 0
241 for relay_url in self.nostr_config.relays:
242 if await self._connect_relay(relay_url):
243 connected_count += 1
245 if connected_count == 0:
246 logger.error("Failed to connect to any relay")
247 self.status = ChannelStatus.ERROR
248 return False
250 # Subscribe to DMs
251 await self._subscribe_to_dms()
253 # Subscribe to mentions
254 await self._subscribe_to_mentions()
256 self.status = ChannelStatus.CONNECTED
257 logger.info(f"Nostr connected to {connected_count} relays as {self._pubkey_hex[:16]}...")
258 return True
260 except Exception as e:
261 logger.error(f"Failed to connect to Nostr: {e}")
262 self.status = ChannelStatus.ERROR
263 return False
265 async def disconnect(self) -> None:
266 """Disconnect from all relays."""
267 # Cancel read tasks
268 for task in self._read_tasks:
269 task.cancel()
270 try:
271 await task
272 except asyncio.CancelledError:
273 pass
275 self._read_tasks.clear()
277 # Close relay connections
278 for relay in self._relays.values():
279 if relay.ws:
280 await relay.ws.close()
281 relay.ws = None
282 relay.connected = False
284 self._relays.clear()
285 self._subscriptions.clear()
286 self._seen_events.clear()
287 self.status = ChannelStatus.DISCONNECTED
289 def _parse_private_key(self) -> None:
290 """Parse private key from hex or nsec format."""
291 key = self.nostr_config.private_key
293 # Handle nsec format (NIP-19)
294 if key.startswith("nsec1"):
295 key = self._bech32_decode(key, "nsec")
297 # Convert hex to bytes
298 try:
299 self._private_key = bytes.fromhex(key)
300 except ValueError:
301 logger.error("Invalid private key format")
303 def _derive_public_key(self) -> None:
304 """Derive public key from private key."""
305 if not self._private_key:
306 return
308 if HAS_SECP256K1:
309 pk = PrivateKey(self._private_key, raw=True)
310 self._public_key = pk.pubkey.serialize()[1:] # Remove 04 prefix
311 self._pubkey_hex = self._public_key.hex()
312 else:
313 # Fallback: simple derivation (not secure, for testing only)
314 logger.warning("secp256k1 not available, using insecure key derivation")
315 self._public_key = hashlib.sha256(self._private_key).digest()
316 self._pubkey_hex = self._public_key.hex()
318 def _bech32_decode(self, bech32_str: str, hrp: str) -> str:
319 """Decode bech32 string (simplified NIP-19)."""
320 # Simplified decoder - in production use a proper bech32 library
321 # This is a placeholder
322 logger.warning("Using simplified bech32 decoder")
323 # Strip hrp and decode base32
324 data_part = bech32_str[len(hrp) + 1:]
325 # Return as hex (placeholder - actual implementation needs bech32 library)
326 return data_part
328 async def _connect_relay(self, relay_url: str) -> bool:
329 """Connect to a single relay."""
330 try:
331 ws = await websockets.connect(
332 relay_url,
333 ping_interval=30,
334 ping_timeout=10,
335 )
337 relay = RelayConnection(url=relay_url, ws=ws, connected=True)
338 self._relays[relay_url] = relay
340 # Start read task
341 task = asyncio.create_task(self._read_relay(relay))
342 self._read_tasks.append(task)
344 logger.info(f"Connected to relay: {relay_url}")
345 return True
347 except Exception as e:
348 logger.error(f"Failed to connect to relay {relay_url}: {e}")
349 return False
351 async def _read_relay(self, relay: RelayConnection) -> None:
352 """Read messages from a relay."""
353 while relay.connected and relay.ws:
354 try:
355 raw = await relay.ws.recv()
356 await self._handle_relay_message(relay, raw)
358 except ConnectionClosed:
359 logger.warning(f"Relay disconnected: {relay.url}")
360 relay.connected = False
361 await self._handle_relay_disconnect(relay)
362 break
364 except asyncio.CancelledError:
365 break
367 except Exception as e:
368 logger.error(f"Relay read error: {e}")
370 async def _handle_relay_message(self, relay: RelayConnection, raw: str) -> None:
371 """Handle raw message from relay."""
372 try:
373 data = json.loads(raw)
374 if not isinstance(data, list) or len(data) < 2:
375 return
377 msg_type = data[0]
379 if msg_type == "EVENT":
380 # ["EVENT", subscription_id, event]
381 if len(data) >= 3:
382 await self._handle_event(relay, data[1], data[2])
384 elif msg_type == "OK":
385 # ["OK", event_id, success, message]
386 if len(data) >= 3:
387 event_id = data[1]
388 success = data[2]
389 if event_id in self._pending_events:
390 self._pending_events[event_id].set()
392 elif msg_type == "EOSE":
393 # ["EOSE", subscription_id]
394 logger.debug(f"End of stored events for subscription: {data[1]}")
396 elif msg_type == "NOTICE":
397 # ["NOTICE", message]
398 logger.info(f"Relay notice from {relay.url}: {data[1]}")
400 elif msg_type == "AUTH":
401 # ["AUTH", challenge] (NIP-42)
402 if len(data) >= 2:
403 await self._handle_auth_challenge(relay, data[1])
405 except json.JSONDecodeError:
406 logger.warning(f"Invalid JSON from relay: {raw[:100]}")
408 async def _handle_event(
409 self,
410 relay: RelayConnection,
411 subscription_id: str,
412 event_data: Dict[str, Any],
413 ) -> None:
414 """Handle incoming Nostr event."""
415 try:
416 event = NostrEvent.from_dict(event_data)
418 # Skip if already seen
419 if event.id in self._seen_events:
420 return
422 self._seen_events.add(event.id)
424 # Verify event signature
425 if not self._verify_event(event):
426 logger.warning(f"Invalid event signature: {event.id}")
427 return
429 # Skip own events
430 if event.pubkey == self._pubkey_hex:
431 return
433 # Handle by kind
434 if event.kind == NostrEventKind.ENCRYPTED_DM:
435 await self._handle_encrypted_dm(event)
436 elif event.kind == NostrEventKind.TEXT_NOTE:
437 await self._handle_text_note(event)
438 elif event.kind == NostrEventKind.CHANNEL_MESSAGE:
439 await self._handle_channel_message(event)
441 # Call registered handlers
442 if event.kind in self._event_handlers:
443 for handler in self._event_handlers[event.kind]:
444 try:
445 result = handler(event)
446 if asyncio.iscoroutine(result):
447 await result
448 except Exception as e:
449 logger.error(f"Event handler error: {e}")
451 except Exception as e:
452 logger.error(f"Error handling event: {e}")
454 async def _handle_encrypted_dm(self, event: NostrEvent) -> None:
455 """Handle encrypted DM (NIP-04)."""
456 if not self.nostr_config.enable_nip04_encryption:
457 return
459 try:
460 # Get sender pubkey
461 sender_pubkey = event.pubkey
463 # Decrypt content
464 decrypted = self._decrypt_nip04(event.content, sender_pubkey)
465 if not decrypted:
466 logger.warning("Failed to decrypt DM")
467 return
469 message = Message(
470 id=event.id,
471 channel=self.name,
472 sender_id=sender_pubkey,
473 sender_name=self._get_display_name(sender_pubkey),
474 chat_id=f"dm:{sender_pubkey}",
475 text=decrypted,
476 timestamp=datetime.fromtimestamp(event.created_at),
477 is_group=False,
478 raw={
479 "event": event.to_dict(),
480 "relay": "unknown",
481 "encrypted": True,
482 },
483 )
485 await self._dispatch_message(message)
487 except Exception as e:
488 logger.error(f"Error handling encrypted DM: {e}")
490 async def _handle_text_note(self, event: NostrEvent) -> None:
491 """Handle text note (public post)."""
492 # Check if we're mentioned
493 is_mentioned = False
494 for tag in event.tags:
495 if len(tag) >= 2 and tag[0] == "p" and tag[1] == self._pubkey_hex:
496 is_mentioned = True
497 break
499 if not is_mentioned:
500 return
502 message = Message(
503 id=event.id,
504 channel=self.name,
505 sender_id=event.pubkey,
506 sender_name=self._get_display_name(event.pubkey),
507 chat_id=f"note:{event.id}",
508 text=event.content,
509 timestamp=datetime.fromtimestamp(event.created_at),
510 is_group=True,
511 is_bot_mentioned=True,
512 raw={
513 "event": event.to_dict(),
514 "tags": event.tags,
515 },
516 )
518 await self._dispatch_message(message)
520 async def _handle_channel_message(self, event: NostrEvent) -> None:
521 """Handle channel message (NIP-28)."""
522 # Extract channel ID from tags
523 channel_id = None
524 for tag in event.tags:
525 if len(tag) >= 2 and tag[0] == "e":
526 channel_id = tag[1]
527 break
529 if not channel_id:
530 return
532 message = Message(
533 id=event.id,
534 channel=self.name,
535 sender_id=event.pubkey,
536 sender_name=self._get_display_name(event.pubkey),
537 chat_id=f"channel:{channel_id}",
538 text=event.content,
539 timestamp=datetime.fromtimestamp(event.created_at),
540 is_group=True,
541 raw={
542 "event": event.to_dict(),
543 "channel_id": channel_id,
544 },
545 )
547 await self._dispatch_message(message)
549 async def _handle_auth_challenge(self, relay: RelayConnection, challenge: str) -> None:
550 """Handle NIP-42 authentication challenge."""
551 if not self.nostr_config.enable_nip42_auth:
552 return
554 try:
555 # Create auth event
556 auth_event = self._create_event(
557 kind=NostrEventKind.AUTH,
558 content="",
559 tags=[
560 ["relay", relay.url],
561 ["challenge", challenge],
562 ],
563 )
565 # Send auth
566 await relay.ws.send(json.dumps(["AUTH", auth_event.to_dict()]))
567 logger.info(f"Sent NIP-42 auth to {relay.url}")
569 except Exception as e:
570 logger.error(f"Auth challenge error: {e}")
572 async def _handle_relay_disconnect(self, relay: RelayConnection) -> None:
573 """Handle relay disconnection with reconnection."""
574 if relay.reconnect_count < self.nostr_config.reconnect_attempts:
575 relay.reconnect_count += 1
576 delay = self.nostr_config.reconnect_delay * (2 ** (relay.reconnect_count - 1))
578 logger.info(f"Reconnecting to {relay.url} in {delay}s")
579 await asyncio.sleep(delay)
581 if await self._connect_relay(relay.url):
582 # Resubscribe
583 for sub_id in list(relay.subscriptions):
584 if sub_id in self._subscriptions:
585 await self._send_subscription(relay, sub_id, self._subscriptions[sub_id])
587 async def _subscribe_to_dms(self) -> None:
588 """Subscribe to encrypted DMs."""
589 filter = NostrFilter(
590 kinds=[NostrEventKind.ENCRYPTED_DM],
591 tags={"p": [self._pubkey_hex]},
592 since=int(time.time()) - 86400, # Last 24 hours
593 limit=self.nostr_config.subscription_limit,
594 )
596 await self.subscribe("dm_inbox", filter)
598 async def _subscribe_to_mentions(self) -> None:
599 """Subscribe to mentions in text notes."""
600 filter = NostrFilter(
601 kinds=[NostrEventKind.TEXT_NOTE],
602 tags={"p": [self._pubkey_hex]},
603 since=int(time.time()) - 86400,
604 limit=self.nostr_config.subscription_limit,
605 )
607 await self.subscribe("mentions", filter)
609 async def subscribe(self, subscription_id: str, filter: NostrFilter) -> bool:
610 """Subscribe to events matching filter."""
611 self._subscriptions[subscription_id] = filter
613 success = False
614 for relay in self._relays.values():
615 if relay.connected:
616 if await self._send_subscription(relay, subscription_id, filter):
617 relay.subscriptions.add(subscription_id)
618 success = True
620 return success
622 async def _send_subscription(
623 self,
624 relay: RelayConnection,
625 subscription_id: str,
626 filter: NostrFilter,
627 ) -> bool:
628 """Send subscription request to relay."""
629 if not relay.ws or not relay.connected:
630 return False
632 try:
633 msg = ["REQ", subscription_id, filter.to_dict()]
634 await relay.ws.send(json.dumps(msg))
635 return True
636 except Exception as e:
637 logger.error(f"Failed to send subscription: {e}")
638 return False
640 async def unsubscribe(self, subscription_id: str) -> None:
641 """Unsubscribe from events."""
642 if subscription_id in self._subscriptions:
643 del self._subscriptions[subscription_id]
645 for relay in self._relays.values():
646 if relay.ws and relay.connected:
647 try:
648 await relay.ws.send(json.dumps(["CLOSE", subscription_id]))
649 relay.subscriptions.discard(subscription_id)
650 except Exception:
651 pass
653 def _create_event(
654 self,
655 kind: int,
656 content: str,
657 tags: Optional[List[List[str]]] = None,
658 ) -> NostrEvent:
659 """Create and sign a Nostr event."""
660 created_at = int(time.time())
661 tags = tags or []
663 # Add expiry tag if configured
664 if self.nostr_config.message_expiry > 0:
665 tags.append(["expiration", str(created_at + self.nostr_config.message_expiry)])
667 # Compute event ID
668 event_data = [
669 0,
670 self._pubkey_hex,
671 created_at,
672 kind,
673 tags,
674 content,
675 ]
676 event_json = json.dumps(event_data, separators=(",", ":"), ensure_ascii=False)
677 event_id = hashlib.sha256(event_json.encode()).hexdigest()
679 # Sign event
680 sig = self._sign_event(event_id)
682 return NostrEvent(
683 id=event_id,
684 pubkey=self._pubkey_hex,
685 created_at=created_at,
686 kind=kind,
687 tags=tags,
688 content=content,
689 sig=sig,
690 )
692 def _sign_event(self, event_id: str) -> str:
693 """Sign event ID with private key."""
694 if not self._private_key:
695 return ""
697 if HAS_SECP256K1:
698 pk = PrivateKey(self._private_key, raw=True)
699 sig = pk.schnorr_sign(bytes.fromhex(event_id), None, raw=True)
700 return sig.hex()
701 else:
702 # Fallback: insecure placeholder signature
703 logger.warning("secp256k1 not available, using placeholder signature")
704 return hashlib.sha256(
705 self._private_key + bytes.fromhex(event_id)
706 ).hexdigest() * 2
708 def _verify_event(self, event: NostrEvent) -> bool:
709 """Verify event signature."""
710 if HAS_SECP256K1:
711 try:
712 # Recompute event ID
713 event_data = [
714 0,
715 event.pubkey,
716 event.created_at,
717 event.kind,
718 event.tags,
719 event.content,
720 ]
721 event_json = json.dumps(event_data, separators=(",", ":"), ensure_ascii=False)
722 computed_id = hashlib.sha256(event_json.encode()).hexdigest()
724 if computed_id != event.id:
725 return False
727 # Verify signature
728 pubkey = PublicKey(bytes.fromhex("02" + event.pubkey), raw=True)
729 return pubkey.schnorr_verify(
730 bytes.fromhex(event.id),
731 bytes.fromhex(event.sig),
732 None,
733 raw=True,
734 )
735 except Exception:
736 return False
737 else:
738 # Skip verification if secp256k1 not available
739 return True
741 def _encrypt_nip04(self, content: str, recipient_pubkey: str) -> str:
742 """Encrypt content for NIP-04 DM."""
743 if not HAS_SECP256K1 or not HAS_CRYPTO:
744 logger.warning("Encryption libraries not available")
745 return content
747 try:
748 # Compute shared secret
749 pk = PrivateKey(self._private_key, raw=True)
750 recipient_pk = PublicKey(bytes.fromhex("02" + recipient_pubkey), raw=True)
751 shared_point = recipient_pk.tweak_mul(self._private_key)
752 shared_secret = shared_point.serialize()[1:33]
754 # Generate IV
755 iv = secrets.token_bytes(16)
757 # Encrypt with AES-256-CBC
758 cipher = Cipher(
759 algorithms.AES(shared_secret),
760 modes.CBC(iv),
761 backend=default_backend(),
762 )
763 encryptor = cipher.encryptor()
765 # Pad content
766 pad_len = 16 - (len(content) % 16)
767 padded = content.encode() + bytes([pad_len] * pad_len)
769 ciphertext = encryptor.update(padded) + encryptor.finalize()
771 # Format: base64(ciphertext)?iv=base64(iv)
772 import base64
773 ct_b64 = base64.b64encode(ciphertext).decode()
774 iv_b64 = base64.b64encode(iv).decode()
776 return f"{ct_b64}?iv={iv_b64}"
778 except Exception as e:
779 logger.error(f"Encryption error: {e}")
780 return content
782 def _decrypt_nip04(self, content: str, sender_pubkey: str) -> Optional[str]:
783 """Decrypt NIP-04 encrypted content."""
784 if not HAS_SECP256K1 or not HAS_CRYPTO:
785 logger.warning("Decryption libraries not available")
786 return None
788 try:
789 import base64
791 # Parse content
792 if "?iv=" not in content:
793 return None
795 ct_b64, iv_part = content.split("?iv=", 1)
796 ciphertext = base64.b64decode(ct_b64)
797 iv = base64.b64decode(iv_part)
799 # Compute shared secret
800 pk = PrivateKey(self._private_key, raw=True)
801 sender_pk = PublicKey(bytes.fromhex("02" + sender_pubkey), raw=True)
802 shared_point = sender_pk.tweak_mul(self._private_key)
803 shared_secret = shared_point.serialize()[1:33]
805 # Decrypt
806 cipher = Cipher(
807 algorithms.AES(shared_secret),
808 modes.CBC(iv),
809 backend=default_backend(),
810 )
811 decryptor = cipher.decryptor()
813 padded = decryptor.update(ciphertext) + decryptor.finalize()
815 # Remove padding
816 pad_len = padded[-1]
817 plaintext = padded[:-pad_len].decode()
819 return plaintext
821 except Exception as e:
822 logger.error(f"Decryption error: {e}")
823 return None
825 def _get_display_name(self, pubkey: str) -> str:
826 """Get display name for pubkey (placeholder)."""
827 # In production, this would fetch kind:0 metadata
828 return f"nostr:{pubkey[:8]}..."
830 async def send_message(
831 self,
832 chat_id: str,
833 text: str,
834 reply_to: Optional[str] = None,
835 media: Optional[List[MediaAttachment]] = None,
836 buttons: Optional[List[Dict]] = None,
837 ) -> SendResult:
838 """Send a message via Nostr."""
839 try:
840 if chat_id.startswith("dm:"):
841 # Encrypted DM
842 recipient_pubkey = chat_id.replace("dm:", "")
843 return await self.send_dm(recipient_pubkey, text)
845 elif chat_id.startswith("channel:"):
846 # Channel message
847 channel_id = chat_id.replace("channel:", "")
848 return await self.send_channel_message(channel_id, text, reply_to)
850 elif chat_id.startswith("note:"):
851 # Reply to note
852 note_id = chat_id.replace("note:", "")
853 return await self.post_note(text, reply_to=note_id)
855 else:
856 # Public note
857 return await self.post_note(text)
859 except Exception as e:
860 logger.error(f"Failed to send message: {e}")
861 return SendResult(success=False, error=str(e))
863 async def send_dm(self, recipient_pubkey: str, text: str) -> SendResult:
864 """Send encrypted DM (NIP-04)."""
865 if not self.nostr_config.enable_nip04_encryption:
866 return SendResult(success=False, error="NIP-04 encryption disabled")
868 try:
869 # Encrypt content
870 encrypted = self._encrypt_nip04(text, recipient_pubkey)
872 # Create event
873 event = self._create_event(
874 kind=NostrEventKind.ENCRYPTED_DM,
875 content=encrypted,
876 tags=[["p", recipient_pubkey]],
877 )
879 # Publish
880 return await self._publish_event(event)
882 except Exception as e:
883 return SendResult(success=False, error=str(e))
885 async def post_note(
886 self,
887 text: str,
888 reply_to: Optional[str] = None,
889 mentions: Optional[List[str]] = None,
890 ) -> SendResult:
891 """Post a public text note."""
892 try:
893 tags = []
895 # Add reply tag
896 if reply_to:
897 tags.append(["e", reply_to, "", "reply"])
899 # Add mention tags
900 if mentions:
901 for pubkey in mentions:
902 tags.append(["p", pubkey])
904 event = self._create_event(
905 kind=NostrEventKind.TEXT_NOTE,
906 content=text,
907 tags=tags,
908 )
910 return await self._publish_event(event)
912 except Exception as e:
913 return SendResult(success=False, error=str(e))
915 async def send_channel_message(
916 self,
917 channel_id: str,
918 text: str,
919 reply_to: Optional[str] = None,
920 ) -> SendResult:
921 """Send message to Nostr channel (NIP-28)."""
922 try:
923 tags = [["e", channel_id, "", "root"]]
925 if reply_to:
926 tags.append(["e", reply_to, "", "reply"])
928 event = self._create_event(
929 kind=NostrEventKind.CHANNEL_MESSAGE,
930 content=text,
931 tags=tags,
932 )
934 return await self._publish_event(event)
936 except Exception as e:
937 return SendResult(success=False, error=str(e))
939 async def _publish_event(self, event: NostrEvent) -> SendResult:
940 """Publish event to all connected relays."""
941 success = False
942 event_dict = event.to_dict()
944 # Create completion event
945 completion = asyncio.Event()
946 self._pending_events[event.id] = completion
948 for relay in self._relays.values():
949 if relay.ws and relay.connected:
950 try:
951 await relay.ws.send(json.dumps(["EVENT", event_dict]))
952 success = True
953 except Exception as e:
954 logger.error(f"Failed to publish to {relay.url}: {e}")
956 # Wait for confirmation (with timeout)
957 if success:
958 try:
959 await asyncio.wait_for(completion.wait(), timeout=5.0)
960 except asyncio.TimeoutError:
961 logger.warning("Event publish confirmation timeout")
963 # Cleanup
964 self._pending_events.pop(event.id, None)
966 return SendResult(success=success, message_id=event.id if success else None)
968 async def edit_message(
969 self,
970 chat_id: str,
971 message_id: str,
972 text: str,
973 buttons: Optional[List[Dict]] = None,
974 ) -> SendResult:
975 """
976 Edit a Nostr message.
977 Note: Nostr doesn't support editing; posts a correction event.
978 """
979 logger.warning("Nostr doesn't support editing; posting correction")
980 return await self.send_message(chat_id, f"[Correction] {text}")
982 async def delete_message(self, chat_id: str, message_id: str) -> bool:
983 """Request deletion of a Nostr event."""
984 try:
985 event = self._create_event(
986 kind=NostrEventKind.DELETE,
987 content="Deleted by author",
988 tags=[["e", message_id]],
989 )
991 result = await self._publish_event(event)
992 return result.success
994 except Exception as e:
995 logger.error(f"Failed to delete: {e}")
996 return False
998 async def send_typing(self, chat_id: str) -> None:
999 """
1000 Send typing indicator.
1001 Note: Nostr doesn't support typing indicators.
1002 """
1003 pass
1005 async def get_chat_info(self, chat_id: str) -> Optional[Dict[str, Any]]:
1006 """Get information about a Nostr chat."""
1007 if chat_id.startswith("dm:"):
1008 pubkey = chat_id.replace("dm:", "")
1009 return {
1010 "type": "dm",
1011 "pubkey": pubkey,
1012 "display_name": self._get_display_name(pubkey),
1013 }
1014 elif chat_id.startswith("channel:"):
1015 channel_id = chat_id.replace("channel:", "")
1016 return {
1017 "type": "channel",
1018 "channel_id": channel_id,
1019 }
1020 return None
1022 # Nostr-specific methods
1024 def on_event(self, kind: int, handler: Callable[[NostrEvent], Any]) -> None:
1025 """Register a handler for specific event kind."""
1026 if kind not in self._event_handlers:
1027 self._event_handlers[kind] = []
1028 self._event_handlers[kind].append(handler)
1030 async def add_reaction(self, event_id: str, content: str = "+") -> SendResult:
1031 """Add a reaction to an event."""
1032 try:
1033 event = self._create_event(
1034 kind=NostrEventKind.REACTION,
1035 content=content,
1036 tags=[["e", event_id]],
1037 )
1039 return await self._publish_event(event)
1041 except Exception as e:
1042 return SendResult(success=False, error=str(e))
1044 async def repost(self, event_id: str) -> SendResult:
1045 """Repost an event."""
1046 try:
1047 event = self._create_event(
1048 kind=NostrEventKind.REPOST,
1049 content="",
1050 tags=[["e", event_id]],
1051 )
1053 return await self._publish_event(event)
1055 except Exception as e:
1056 return SendResult(success=False, error=str(e))
1058 def get_public_key(self) -> str:
1059 """Get bot's public key in hex format."""
1060 return self._pubkey_hex
1062 def get_npub(self) -> str:
1063 """Get bot's public key in npub format (NIP-19)."""
1064 # Simplified - in production use proper bech32 encoding
1065 return f"npub1{self._pubkey_hex[:60]}"
1068def create_nostr_adapter(
1069 private_key: str = None,
1070 relays: List[str] = None,
1071 **kwargs
1072) -> NostrAdapter:
1073 """
1074 Factory function to create Nostr adapter.
1076 Args:
1077 private_key: Private key in hex or nsec format (or set NOSTR_PRIVATE_KEY env var)
1078 relays: List of relay URLs (or set NOSTR_RELAYS env var, comma-separated)
1079 **kwargs: Additional config options
1081 Returns:
1082 Configured NostrAdapter
1083 """
1084 private_key = private_key or os.getenv("NOSTR_PRIVATE_KEY")
1086 if relays is None:
1087 relays_env = os.getenv("NOSTR_RELAYS", "")
1088 if relays_env:
1089 relays = [r.strip() for r in relays_env.split(",") if r.strip()]
1090 else:
1091 relays = None # Use defaults
1093 if not private_key:
1094 raise ValueError("Nostr private key required")
1096 config = NostrConfig(
1097 private_key=private_key,
1098 **kwargs
1099 )
1101 if relays:
1102 config.relays = relays
1104 return NostrAdapter(config)