该提交新增了完整的 Nostr 去中心化社交网络适配器实现,包含以下核心功能: 1. 基础加密与密钥处理:支持 nsec/npub/hex 格式密钥转换,实现 NIP-04/NIP-17 加解密 2. Relay 管理与健康监控:支持多 Relay 连接、自动重连、健康评分与自动选优 3. 事件与消息处理:实现事件校验、去重、速率限制、消息缓存与状态持久化 4. 个人资料管理:支持发布/导入/合并 Nostr Kind 0 元数据事件 5. 配对机制:实现安全的双向配对通信流程 6. Zap 功能:支持 NIP-57 打赏请求与收据解析 7. NIP-05 验证:实现域名身份验证 8. 配置系统:完整的配置校验与多账户支持 9. 监控与指标:提供连接监控、事件统计与健康快照 10. HTTP API:提供个人资料管理的 RESTful 接口 11. 安装向导:命令行配置向导与初始化流程 所有模块均遵循 Nostr 协议规范,支持多账户、多 Relay 部署,内置安全防护与流量控制机制。
272 lines
12 KiB
Python
272 lines
12 KiB
Python
from __future__ import annotations
|
|
|
|
import json as _json
|
|
import re
|
|
|
|
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
|
|
|
|
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) -> DeliveryResult:
|
|
tags = [["e", original_event_id]]
|
|
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:
|
|
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)
|