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

1""" 

2Nostr Protocol Channel Adapter 

3 

4Implements Nostr decentralized messaging protocol. 

5Based on HevolveBot extension patterns for decentralized networks. 

6 

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""" 

19 

20from __future__ import annotations 

21 

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 

33 

34try: 

35 import websockets 

36 from websockets.exceptions import ConnectionClosed 

37 HAS_WEBSOCKETS = True 

38except ImportError: 

39 HAS_WEBSOCKETS = False 

40 

41try: 

42 from secp256k1 import PrivateKey, PublicKey 

43 HAS_SECP256K1 = True 

44except ImportError: 

45 HAS_SECP256K1 = False 

46 

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 

53 

54from ..base import ( 

55 ChannelAdapter, 

56 ChannelConfig, 

57 ChannelStatus, 

58 Message, 

59 MessageType, 

60 MediaAttachment, 

61 SendResult, 

62 ChannelConnectionError, 

63 ChannelSendError, 

64 ChannelRateLimitError, 

65) 

66 

67logger = logging.getLogger(__name__) 

68 

69 

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 

87 

88 

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 

105 

106 

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 

117 

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 } 

129 

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 ) 

142 

143 

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) 

154 

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 

173 

174 

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 

183 

184 

185class NostrAdapter(ChannelAdapter): 

186 """ 

187 Nostr protocol adapter with multi-relay support. 

188 

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 """ 

198 

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 ) 

205 

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] = {} 

217 

218 @property 

219 def name(self) -> str: 

220 return "nostr" 

221 

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 

227 

228 try: 

229 # Parse private key 

230 self._parse_private_key() 

231 

232 if not self._private_key: 

233 logger.error("Failed to parse private key") 

234 return False 

235 

236 # Derive public key 

237 self._derive_public_key() 

238 

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 

244 

245 if connected_count == 0: 

246 logger.error("Failed to connect to any relay") 

247 self.status = ChannelStatus.ERROR 

248 return False 

249 

250 # Subscribe to DMs 

251 await self._subscribe_to_dms() 

252 

253 # Subscribe to mentions 

254 await self._subscribe_to_mentions() 

255 

256 self.status = ChannelStatus.CONNECTED 

257 logger.info(f"Nostr connected to {connected_count} relays as {self._pubkey_hex[:16]}...") 

258 return True 

259 

260 except Exception as e: 

261 logger.error(f"Failed to connect to Nostr: {e}") 

262 self.status = ChannelStatus.ERROR 

263 return False 

264 

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 

274 

275 self._read_tasks.clear() 

276 

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 

283 

284 self._relays.clear() 

285 self._subscriptions.clear() 

286 self._seen_events.clear() 

287 self.status = ChannelStatus.DISCONNECTED 

288 

289 def _parse_private_key(self) -> None: 

290 """Parse private key from hex or nsec format.""" 

291 key = self.nostr_config.private_key 

292 

293 # Handle nsec format (NIP-19) 

294 if key.startswith("nsec1"): 

295 key = self._bech32_decode(key, "nsec") 

296 

297 # Convert hex to bytes 

298 try: 

299 self._private_key = bytes.fromhex(key) 

300 except ValueError: 

301 logger.error("Invalid private key format") 

302 

303 def _derive_public_key(self) -> None: 

304 """Derive public key from private key.""" 

305 if not self._private_key: 

306 return 

307 

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() 

317 

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 

327 

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 ) 

336 

337 relay = RelayConnection(url=relay_url, ws=ws, connected=True) 

338 self._relays[relay_url] = relay 

339 

340 # Start read task 

341 task = asyncio.create_task(self._read_relay(relay)) 

342 self._read_tasks.append(task) 

343 

344 logger.info(f"Connected to relay: {relay_url}") 

345 return True 

346 

347 except Exception as e: 

348 logger.error(f"Failed to connect to relay {relay_url}: {e}") 

349 return False 

350 

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) 

357 

358 except ConnectionClosed: 

359 logger.warning(f"Relay disconnected: {relay.url}") 

360 relay.connected = False 

361 await self._handle_relay_disconnect(relay) 

362 break 

363 

364 except asyncio.CancelledError: 

365 break 

366 

367 except Exception as e: 

368 logger.error(f"Relay read error: {e}") 

369 

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 

376 

377 msg_type = data[0] 

378 

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]) 

383 

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() 

391 

392 elif msg_type == "EOSE": 

393 # ["EOSE", subscription_id] 

394 logger.debug(f"End of stored events for subscription: {data[1]}") 

395 

396 elif msg_type == "NOTICE": 

397 # ["NOTICE", message] 

398 logger.info(f"Relay notice from {relay.url}: {data[1]}") 

399 

400 elif msg_type == "AUTH": 

401 # ["AUTH", challenge] (NIP-42) 

402 if len(data) >= 2: 

403 await self._handle_auth_challenge(relay, data[1]) 

404 

405 except json.JSONDecodeError: 

406 logger.warning(f"Invalid JSON from relay: {raw[:100]}") 

407 

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) 

417 

418 # Skip if already seen 

419 if event.id in self._seen_events: 

420 return 

421 

422 self._seen_events.add(event.id) 

423 

424 # Verify event signature 

425 if not self._verify_event(event): 

426 logger.warning(f"Invalid event signature: {event.id}") 

427 return 

428 

429 # Skip own events 

430 if event.pubkey == self._pubkey_hex: 

431 return 

432 

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) 

440 

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}") 

450 

451 except Exception as e: 

452 logger.error(f"Error handling event: {e}") 

453 

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 

458 

459 try: 

460 # Get sender pubkey 

461 sender_pubkey = event.pubkey 

462 

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 

468 

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 ) 

484 

485 await self._dispatch_message(message) 

486 

487 except Exception as e: 

488 logger.error(f"Error handling encrypted DM: {e}") 

489 

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 

498 

499 if not is_mentioned: 

500 return 

501 

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 ) 

517 

518 await self._dispatch_message(message) 

519 

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 

528 

529 if not channel_id: 

530 return 

531 

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 ) 

546 

547 await self._dispatch_message(message) 

548 

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 

553 

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 ) 

564 

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}") 

568 

569 except Exception as e: 

570 logger.error(f"Auth challenge error: {e}") 

571 

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)) 

577 

578 logger.info(f"Reconnecting to {relay.url} in {delay}s") 

579 await asyncio.sleep(delay) 

580 

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]) 

586 

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 ) 

595 

596 await self.subscribe("dm_inbox", filter) 

597 

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 ) 

606 

607 await self.subscribe("mentions", filter) 

608 

609 async def subscribe(self, subscription_id: str, filter: NostrFilter) -> bool: 

610 """Subscribe to events matching filter.""" 

611 self._subscriptions[subscription_id] = filter 

612 

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 

619 

620 return success 

621 

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 

631 

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 

639 

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] 

644 

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 

652 

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 [] 

662 

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)]) 

666 

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() 

678 

679 # Sign event 

680 sig = self._sign_event(event_id) 

681 

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 ) 

691 

692 def _sign_event(self, event_id: str) -> str: 

693 """Sign event ID with private key.""" 

694 if not self._private_key: 

695 return "" 

696 

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 

707 

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() 

723 

724 if computed_id != event.id: 

725 return False 

726 

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 

740 

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 

746 

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] 

753 

754 # Generate IV 

755 iv = secrets.token_bytes(16) 

756 

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() 

764 

765 # Pad content 

766 pad_len = 16 - (len(content) % 16) 

767 padded = content.encode() + bytes([pad_len] * pad_len) 

768 

769 ciphertext = encryptor.update(padded) + encryptor.finalize() 

770 

771 # Format: base64(ciphertext)?iv=base64(iv) 

772 import base64 

773 ct_b64 = base64.b64encode(ciphertext).decode() 

774 iv_b64 = base64.b64encode(iv).decode() 

775 

776 return f"{ct_b64}?iv={iv_b64}" 

777 

778 except Exception as e: 

779 logger.error(f"Encryption error: {e}") 

780 return content 

781 

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 

787 

788 try: 

789 import base64 

790 

791 # Parse content 

792 if "?iv=" not in content: 

793 return None 

794 

795 ct_b64, iv_part = content.split("?iv=", 1) 

796 ciphertext = base64.b64decode(ct_b64) 

797 iv = base64.b64decode(iv_part) 

798 

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] 

804 

805 # Decrypt 

806 cipher = Cipher( 

807 algorithms.AES(shared_secret), 

808 modes.CBC(iv), 

809 backend=default_backend(), 

810 ) 

811 decryptor = cipher.decryptor() 

812 

813 padded = decryptor.update(ciphertext) + decryptor.finalize() 

814 

815 # Remove padding 

816 pad_len = padded[-1] 

817 plaintext = padded[:-pad_len].decode() 

818 

819 return plaintext 

820 

821 except Exception as e: 

822 logger.error(f"Decryption error: {e}") 

823 return None 

824 

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]}..." 

829 

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) 

844 

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) 

849 

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) 

854 

855 else: 

856 # Public note 

857 return await self.post_note(text) 

858 

859 except Exception as e: 

860 logger.error(f"Failed to send message: {e}") 

861 return SendResult(success=False, error=str(e)) 

862 

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") 

867 

868 try: 

869 # Encrypt content 

870 encrypted = self._encrypt_nip04(text, recipient_pubkey) 

871 

872 # Create event 

873 event = self._create_event( 

874 kind=NostrEventKind.ENCRYPTED_DM, 

875 content=encrypted, 

876 tags=[["p", recipient_pubkey]], 

877 ) 

878 

879 # Publish 

880 return await self._publish_event(event) 

881 

882 except Exception as e: 

883 return SendResult(success=False, error=str(e)) 

884 

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 = [] 

894 

895 # Add reply tag 

896 if reply_to: 

897 tags.append(["e", reply_to, "", "reply"]) 

898 

899 # Add mention tags 

900 if mentions: 

901 for pubkey in mentions: 

902 tags.append(["p", pubkey]) 

903 

904 event = self._create_event( 

905 kind=NostrEventKind.TEXT_NOTE, 

906 content=text, 

907 tags=tags, 

908 ) 

909 

910 return await self._publish_event(event) 

911 

912 except Exception as e: 

913 return SendResult(success=False, error=str(e)) 

914 

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"]] 

924 

925 if reply_to: 

926 tags.append(["e", reply_to, "", "reply"]) 

927 

928 event = self._create_event( 

929 kind=NostrEventKind.CHANNEL_MESSAGE, 

930 content=text, 

931 tags=tags, 

932 ) 

933 

934 return await self._publish_event(event) 

935 

936 except Exception as e: 

937 return SendResult(success=False, error=str(e)) 

938 

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() 

943 

944 # Create completion event 

945 completion = asyncio.Event() 

946 self._pending_events[event.id] = completion 

947 

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}") 

955 

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") 

962 

963 # Cleanup 

964 self._pending_events.pop(event.id, None) 

965 

966 return SendResult(success=success, message_id=event.id if success else None) 

967 

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}") 

981 

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 ) 

990 

991 result = await self._publish_event(event) 

992 return result.success 

993 

994 except Exception as e: 

995 logger.error(f"Failed to delete: {e}") 

996 return False 

997 

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 

1004 

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 

1021 

1022 # Nostr-specific methods 

1023 

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) 

1029 

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 ) 

1038 

1039 return await self._publish_event(event) 

1040 

1041 except Exception as e: 

1042 return SendResult(success=False, error=str(e)) 

1043 

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 ) 

1052 

1053 return await self._publish_event(event) 

1054 

1055 except Exception as e: 

1056 return SendResult(success=False, error=str(e)) 

1057 

1058 def get_public_key(self) -> str: 

1059 """Get bot's public key in hex format.""" 

1060 return self._pubkey_hex 

1061 

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]}" 

1066 

1067 

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. 

1075 

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 

1080 

1081 Returns: 

1082 Configured NostrAdapter 

1083 """ 

1084 private_key = private_key or os.getenv("NOSTR_PRIVATE_KEY") 

1085 

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 

1092 

1093 if not private_key: 

1094 raise ValueError("Nostr private key required") 

1095 

1096 config = NostrConfig( 

1097 private_key=private_key, 

1098 **kwargs 

1099 ) 

1100 

1101 if relays: 

1102 config.relays = relays 

1103 

1104 return NostrAdapter(config)