from __future__ import annotations import json as _json import re import time from yuxi.channels.models import ChannelResponse, DeliveryResult from yuxi.channels.adapters.nostr.crypto import NostrCrypto, NostrCryptoError from yuxi.channels.adapters.nostr.config import NostrConfig from yuxi.channels.adapters.nostr.relay_manager import RelayManager from yuxi.utils.logging_config import logger class NostrSender: def __init__(self, crypto: NostrCrypto, relay_manager: RelayManager, config: NostrConfig): self._crypto = crypto self._relay_manager = relay_manager self._config = config self._typing_throttle: dict[str, float] = {} self._typing_interval_sec = 5.0 async def send(self, response: ChannelResponse) -> DeliveryResult: try: chunks = self._chunk_text(response.content) results = [] for chunk in chunks: chunk_response = ChannelResponse( identity=response.identity, content=chunk, metadata=response.metadata, reply_to_message_id=response.reply_to_message_id, ) event = await self.format_outbound(chunk_response) success_count = await self._relay_manager.broadcast(event) results.append(success_count > 0) all_success = all(results) event = await self.format_outbound(response) event_id = event.get("id", "") if all_success: return DeliveryResult(success=True, message_id=event_id) return DeliveryResult(success=False, error="部分分块或所有 Relay 发送失败") except NostrCryptoError as e: logger.error(f"Nostr 加密错误: {e}") return DeliveryResult(success=False, error=f"加密失败: {e}") except Exception as e: logger.error(f"Nostr 发送消息失败: {e}") return DeliveryResult(success=False, error=str(e)) def _chunk_text(self, text: str) -> list[str]: limit = 4096 if len(text) <= limit: return [text] chunks = [] while len(text) > limit: split_at = text.rfind("\n", 0, limit) if split_at == -1 or split_at < limit // 2: split_at = text.rfind(" ", 0, limit) if split_at == -1 or split_at < limit // 2: split_at = limit chunks.append(text[:split_at]) text = text[split_at:].lstrip() if text: chunks.append(text) return chunks async def _resolve_send_kind(self, receiver_pubkey: str, chat_type: str) -> tuple[int, str]: if chat_type == "group" or not receiver_pubkey: return 1, "plain" if self._config.nip17_enabled: return 1059, "nip17" return 4, "nip04" async def _encrypt_for_dm(self, content: str, receiver_pubkey: str, encrypt_mode: str) -> str: if encrypt_mode == "nip17": return await self._crypto.encrypt_nip17(content, receiver_pubkey) elif encrypt_mode == "nip04": return self._crypto.encrypt_nip04(content, receiver_pubkey) return content async def format_outbound(self, response: ChannelResponse) -> dict: content = response.content if self._config.markdown_table_mode == "convert": content = self.convert_markdown_tables(content) tags: list[list[str]] = [] receiver_pubkey = response.metadata.get("receiver_pubkey", "") chat_id = response.identity.channel_chat_id if chat_id and not receiver_pubkey: chat_id_parts = chat_id.split(":") if len(chat_id_parts) > 1: receiver_pubkey = chat_id_parts[-1] chat_type = response.metadata.get("chat_type", "direct") kind, encrypt_mode = await self._resolve_send_kind(receiver_pubkey, chat_type) if encrypt_mode == "nip17" and receiver_pubkey: gift_wrap_json = await self._crypto.encrypt_nip17(content, receiver_pubkey) event = _json.loads(gift_wrap_json) if response.reply_to_message_id: event_tags = event.get("tags", []) root_id = response.metadata.get("root_event_id", response.reply_to_message_id) event_tags.append(["e", root_id, "", "root"]) if response.reply_to_message_id != root_id: event_tags.append(["e", response.reply_to_message_id, "", "reply"]) event["tags"] = event_tags return event if receiver_pubkey and encrypt_mode != "plain": content = await self._encrypt_for_dm(content, receiver_pubkey, encrypt_mode) tags.append(["p", receiver_pubkey]) if response.reply_to_message_id: root_id = response.metadata.get("root_event_id", response.reply_to_message_id) tags.append(["e", root_id, "", "root"]) if response.reply_to_message_id != root_id: tags.append(["e", response.reply_to_message_id, "", "reply"]) event = self._crypto.build_and_sign_event(kind=kind, content=content, tags=tags) return event async def send_reaction(self, target_event_id: str, target_pubkey: str, emoji: str) -> DeliveryResult: tags = [ ["e", target_event_id], ["p", target_pubkey], ] event = self._crypto.build_and_sign_event(kind=7, content=emoji, tags=tags) success_count = await self._relay_manager.broadcast(event) return DeliveryResult( success=success_count > 0, message_id=event.get("id") if success_count > 0 else None, error="所有 Relay 发送失败" if success_count == 0 else None, ) async def send_edit( self, original_event_id: str, new_content: str, receiver_pubkey: str = "", chat_type: str = "direct", ) -> DeliveryResult: tags: list[list[str]] = [["e", original_event_id, "", "edit"]] content = new_content kind, encrypt_mode = await self._resolve_send_kind(receiver_pubkey, chat_type) if receiver_pubkey and encrypt_mode != "plain": tags.append(["p", receiver_pubkey]) content = await self._encrypt_for_dm(new_content, receiver_pubkey, encrypt_mode) event = self._crypto.build_and_sign_event(kind=kind, content=content, tags=tags) success_count = await self._relay_manager.broadcast(event) return DeliveryResult( success=success_count > 0, message_id=event.get("id") if success_count > 0 else None, error="所有 Relay 发送失败" if success_count == 0 else None, ) async def send_delete( self, original_event_id: str, receiver_pubkey: str = "", chat_type: str = "direct" ) -> DeliveryResult: tags: list[list[str]] = [["e", original_event_id]] if receiver_pubkey and chat_type == "direct": tags.append(["p", receiver_pubkey]) content = "deleted" event = self._crypto.build_and_sign_event(kind=5, content=content, tags=tags) success_count = await self._relay_manager.broadcast(event) return DeliveryResult( success=success_count > 0, message_id=event.get("id") if success_count > 0 else None, error="所有 Relay 发送失败" if success_count == 0 else None, ) async def send_typing(self, chat_id: str, receiver_pubkey: str = "") -> DeliveryResult: now = time.monotonic() last = self._typing_throttle.get(chat_id, 0) if now - last < self._typing_interval_sec: return DeliveryResult(success=True, message_id=None) self._typing_throttle[chat_id] = now chat_type = "group" if chat_id.startswith("channel:") else "direct" tags: list[list[str]] = [] content = "typing" kind, encrypt_mode = await self._resolve_send_kind(receiver_pubkey, chat_type) if receiver_pubkey and encrypt_mode != "plain": tags.append(["p", receiver_pubkey]) content = await self._encrypt_for_dm("typing", receiver_pubkey, encrypt_mode) event = self._crypto.build_and_sign_event(kind=kind, content=content, tags=tags) success_count = await self._relay_manager.broadcast(event) return DeliveryResult( success=success_count > 0, message_id=event.get("id") if success_count > 0 else None, ) async def remove_reaction(self, target_event_id: str, target_pubkey: str) -> DeliveryResult: tags = [ ["e", target_event_id], ["p", target_pubkey], ] event = self._crypto.build_and_sign_event(kind=7, content="", tags=tags) success_count = await self._relay_manager.broadcast(event) return DeliveryResult( success=success_count > 0, message_id=event.get("id") if success_count > 0 else None, error="所有 Relay 发送失败" if success_count == 0 else None, ) async def send_media_text( self, media_url: str, caption: str, receiver_pubkey: str = "", chat_type: str = "direct", ) -> DeliveryResult: tags: list[list[str]] = [["url", media_url]] content = caption or media_url kind, encrypt_mode = await self._resolve_send_kind(receiver_pubkey, chat_type) if receiver_pubkey and encrypt_mode != "plain": tags.append(["p", receiver_pubkey]) content = await self._encrypt_for_dm(content, receiver_pubkey, encrypt_mode) event = self._crypto.build_and_sign_event(kind=kind, content=content, tags=tags) success_count = await self._relay_manager.broadcast(event) return DeliveryResult( success=success_count > 0, message_id=event.get("id") if success_count > 0 else None, error="所有 Relay 发送失败" if success_count == 0 else None, ) @staticmethod def convert_markdown_tables(text: str) -> str: table_pattern = re.compile( r"^\|.*\|$\n^\|[\s\-:|]+\|$\n(?:^\|.*\|$\n?)+", re.MULTILINE, ) if not table_pattern.search(text): return text def _convert_table(match: re.Match) -> str: table_str = match.group(0) lines = table_str.strip().split("\n") header_line = lines[0] delimiter_line = lines[1] data_lines = lines[2:] alignments: list[str] = [] for cell in delimiter_line.strip("|").split("|"): cell = cell.strip() left = cell.startswith(":") right = cell.endswith(":") if left and right: alignments.append("center") elif right: alignments.append("right") else: alignments.append("left") def _row_to_line(cells: list[str], aligns: list[str]) -> str: parts: list[str] = [] for i, cell in enumerate(cells): cell = cell.strip() align = aligns[i] if i < len(aligns) else "left" col_width = max(3, len(cell)) if align == "right": parts.append(cell.rjust(col_width)) elif align == "center": parts.append(cell.center(col_width)) else: parts.append(cell.ljust(col_width)) return " ".join(parts) result_lines: list[str] = [] if header_line: headers = [h.strip() for h in header_line.strip("|").split("|")] result_lines.append(_row_to_line(headers, alignments)) result_lines.append("-" * len(result_lines[0]) if result_lines else "---") for line in data_lines: cells = [c.strip() for c in line.strip("|").split("|")] result_lines.append(_row_to_line(cells, alignments)) return "\n".join(result_lines) return table_pattern.sub(_convert_table, text)