ForcePilot/backend/package/yuxi/channels/adapters/nostr/send.py
Kris 1f78c44b03 refactor: 整理并清理项目中的冗余代码与格式问题
这是一个批量整理提交,包含以下主要改动:
1.  删除多处冗余的空行和未使用的导入
2.  修复文件末尾缺少换行符的问题
3.  调整部分模块的导入顺序与代码排版
4.  修复部分配置默认值与策略逻辑
5.  新增多个功能模块与辅助工具
6.  完善异常处理与日志记录
7.  修复速率限制、消息缓存、权限校验等逻辑bug
8.  废弃部分旧有API与配置项并添加警告提示
2026-05-12 14:51:53 +08:00

285 lines
12 KiB
Python

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)