ForcePilot/backend/package/yuxi/channels/adapters/signal/send.py
Kris 8dc86766f1 feat(channels/signal): 新增Signal渠道适配器完整实现
新增了完整的Signal渠道适配器实现,包含RPC客户端、守护进程管理、安全策略、消息处理、安装配置工具等全套功能,支持通过signal-cli与Signal网络进行通信,包含账户管理、消息收发、反应处理、媒体分析、健康检查等能力。
2026-05-12 00:48:25 +08:00

429 lines
15 KiB
Python

from __future__ import annotations
import asyncio
import base64
import logging
import uuid
from yuxi.channels.models import DeliveryResult
from yuxi.channels.adapters.signal.client import RpcClient, RpcError
from yuxi.channels.adapters.signal.format import (
FormattedText,
StyleRange,
clamp_styles_to_length,
split_text,
)
from yuxi.channels.adapters.signal.normalize import normalize_target
logger = logging.getLogger(__name__)
GROUP_PREFIX = "group:"
def _make_message_id(timestamp: int | str | None) -> str | None:
if not timestamp:
return None
short_uuid = uuid.uuid4().hex[:8]
return f"{timestamp}:{short_uuid}"
class SignalSender:
MAX_TEXT_LENGTH = 4000
MAX_RETRIES = 3
RETRY_BASE_DELAY = 0.5
CACHE_TTL = 3600
CACHE_MAX_SIZE = 1000
_sent_message_cache: dict[str, dict] = {}
_cache_access_order: list[str] = []
def __init__(self, rpc_client: RpcClient, account_number: str):
self._rpc = rpc_client
self._account = account_number
@staticmethod
def _recipient_param(target: str) -> dict:
normalized = normalize_target(target)
if normalized.startswith(GROUP_PREFIX):
return {"groupId": normalized}
return {"recipient": normalized}
def _cache_sent(self, msg_id: str, recipient: str) -> None:
self._prune_cache()
if len(self._sent_message_cache) >= self.CACHE_MAX_SIZE:
oldest = self._cache_access_order.pop(0)
self._sent_message_cache.pop(oldest, None)
self._sent_message_cache[msg_id] = {
"recipient": normalize_target(recipient),
"timestamp": asyncio.get_event_loop().time(),
}
self._cache_access_order.append(msg_id)
def _get_cached(self, msg_id: str) -> dict | None:
entry = self._sent_message_cache.get(msg_id)
if entry is None:
return None
now = asyncio.get_event_loop().time()
if now - entry["timestamp"] > self.CACHE_TTL:
self._sent_message_cache.pop(msg_id, None)
if msg_id in self._cache_access_order:
self._cache_access_order.remove(msg_id)
return None
if msg_id in self._cache_access_order:
self._cache_access_order.remove(msg_id)
self._cache_access_order.append(msg_id)
return entry
@classmethod
def _prune_cache(cls) -> None:
now = asyncio.get_event_loop().time()
expired = [k for k, v in cls._sent_message_cache.items() if now - v["timestamp"] > cls.CACHE_TTL]
for k in expired:
cls._sent_message_cache.pop(k, None)
if k in cls._cache_access_order:
cls._cache_access_order.remove(k)
async def _call_with_retry(self, method: str, params: dict) -> dict:
last_error = None
for attempt in range(self.MAX_RETRIES):
try:
return await self._rpc.call(method, params)
except RpcError:
raise
except Exception as e:
last_error = e
if attempt < self.MAX_RETRIES - 1:
delay = self.RETRY_BASE_DELAY * (2**attempt)
await asyncio.sleep(delay)
raise last_error # type: ignore[misc]
async def send_text(
self,
recipient: str,
message_body: str,
reply_to_id: str | None = None,
formatted_body: FormattedText | None = None,
chunk_mode: str = "newline",
text_mode: str = "markdown",
) -> DeliveryResult:
if text_mode == "plain" and formatted_body is not None:
return await self._send_formatted_text(recipient, formatted_body, reply_to_id, chunk_mode)
if formatted_body is not None and text_mode == "markdown":
return await self._send_formatted_text(recipient, formatted_body, reply_to_id, chunk_mode)
chunks = split_text(message_body, self.MAX_TEXT_LENGTH, chunk_mode)
last_message_id = None
for i, chunk in enumerate(chunks):
params = {
"account": self._account,
**self._recipient_param(recipient),
"messageBody": chunk,
}
if reply_to_id and i == 0:
params["quoteTimestamp"] = int(reply_to_id)
try:
result = await self._call_with_retry("send", params)
last_message_id = _make_message_id(result.get("timestamp"))
if last_message_id:
self._cache_sent(last_message_id, recipient)
except Exception as e:
logger.error(f"Failed to send message to {recipient}: {e}")
return DeliveryResult(success=False, error=str(e))
return DeliveryResult(success=True, message_id=last_message_id)
async def _send_formatted_text(
self,
recipient: str,
formatted: FormattedText,
reply_to_id: str | None = None,
chunk_mode: str = "newline",
) -> DeliveryResult:
chunks = split_text(formatted.body, self.MAX_TEXT_LENGTH, chunk_mode)
last_message_id = None
offset = 0
for i, chunk in enumerate(chunks):
chunk_styles = _extract_chunk_styles(formatted.styles, offset, len(chunk))
clamped = clamp_styles_to_length(chunk_styles, chunk)
params: dict = {
"account": self._account,
**self._recipient_param(recipient),
"messageBody": chunk,
}
if clamped:
params["styledBody"] = chunk
params["styles"] = [{"start": s.start, "length": s.length, "style": s.style} for s in clamped]
if reply_to_id and i == 0:
params["quoteTimestamp"] = int(reply_to_id)
try:
result = await self._call_with_retry("send", params)
last_message_id = _make_message_id(result.get("timestamp"))
if last_message_id:
self._cache_sent(last_message_id, recipient)
except Exception as e:
logger.error(f"Failed to send formatted message to {recipient}: {e}")
return DeliveryResult(success=False, error=str(e))
offset += len(chunk)
return DeliveryResult(success=True, message_id=last_message_id)
async def send_media(
self,
recipient: str,
media_data: bytes,
media_type: str,
filename: str | None = None,
caption: str | None = None,
) -> DeliveryResult:
content_type_map = {
"image": "image/jpeg",
"video": "video/mp4",
"audio": "audio/ogg",
"file": "application/octet-stream",
}
params = {
"account": self._account,
**self._recipient_param(recipient),
"messageBody": caption or "",
"attachments": [
{
"contentType": content_type_map.get(media_type, "application/octet-stream"),
"filename": filename or "attachment",
"data": base64.b64encode(media_data).decode("utf-8"),
}
],
}
try:
result = await self._rpc.call("send", params)
return DeliveryResult(
success=True,
message_id=_make_message_id(result.get("timestamp")),
)
except Exception as e:
logger.error(f"Failed to send media to {recipient}: {e}")
return DeliveryResult(success=False, error=str(e))
async def send_reaction(
self,
recipient: str,
target_author: str,
target_sent_timestamp: int,
reaction: str,
remove: bool = False,
) -> DeliveryResult:
method = "removeReaction" if remove else "sendReaction"
params = {
"account": self._account,
**self._recipient_param(recipient),
"targetAuthor": normalize_target(target_author),
"targetSentTimestamp": target_sent_timestamp,
"reaction": reaction,
}
try:
result = await self._rpc.call(method, params)
return DeliveryResult(
success=True,
message_id=_make_message_id(result.get("timestamp")),
)
except Exception as e:
logger.error(f"Failed to send reaction to {recipient}: {e}")
return DeliveryResult(success=False, error=str(e))
async def send_typing_indicator(self, recipient: str) -> DeliveryResult:
try:
await self._rpc.call(
"sendTyping",
{
"account": self._account,
**self._recipient_param(recipient),
},
)
return DeliveryResult(success=True)
except Exception as e:
return DeliveryResult(success=False, error=str(e))
async def send_read_receipt(self, recipient: str, timestamps: list[int]) -> DeliveryResult:
try:
await self._rpc.call(
"sendReadReceipt",
{
"account": self._account,
**self._recipient_param(recipient),
"timestamps": timestamps,
},
)
return DeliveryResult(success=True)
except Exception as e:
return DeliveryResult(success=False, error=str(e))
async def edit_message(
self,
recipient: str,
target_author: str,
target_sent_timestamp: int,
new_body: str,
) -> DeliveryResult:
try:
result = await self._rpc.call(
"editMessage",
{
"account": self._account,
**self._recipient_param(recipient),
"targetAuthor": normalize_target(target_author),
"targetSentTimestamp": target_sent_timestamp,
"newMessageBody": new_body,
},
)
return DeliveryResult(
success=True,
message_id=_make_message_id(result.get("timestamp")),
)
except Exception as e:
logger.warning(f"editMessage failed for {recipient}: {e}")
return DeliveryResult(success=False, error=str(e))
async def delete_message(self, recipient: str, timestamps: list[int]) -> DeliveryResult:
try:
await self._rpc.call(
"remoteDelete",
{
"account": self._account,
**self._recipient_param(recipient),
"timestamps": timestamps,
},
)
return DeliveryResult(success=True)
except Exception as e:
logger.error(f"Failed to delete message for {recipient}: {e}")
return DeliveryResult(success=False, error=str(e))
async def send_sticker(
self,
recipient: str,
sticker_pack_id: str,
sticker_id: int,
) -> DeliveryResult:
try:
params = {
"account": self._account,
**self._recipient_param(recipient),
"stickerPackId": sticker_pack_id,
"stickerId": sticker_id,
}
result = await self._rpc.call("sendSticker", params)
return DeliveryResult(
success=True,
message_id=_make_message_id(result.get("timestamp")),
)
except Exception as e:
logger.error(f"Failed to send sticker to {recipient}: {e}")
return DeliveryResult(success=False, error=str(e))
async def send_silent_message(
self,
recipient: str,
message_body: str,
reply_to_id: str | None = None,
) -> DeliveryResult:
try:
params = {
"account": self._account,
**self._recipient_param(recipient),
"messageBody": message_body,
"disableNotification": True,
}
if reply_to_id:
params["quoteTimestamp"] = int(reply_to_id)
result = await self._rpc.call("send", params)
return DeliveryResult(
success=True,
message_id=_make_message_id(result.get("timestamp")),
)
except Exception as e:
logger.error(f"Failed to send silent message to {recipient}: {e}")
return DeliveryResult(success=False, error=str(e))
async def pin_message(
self,
recipient: str,
message_timestamp: int,
) -> DeliveryResult:
try:
await self._rpc.call(
"pinMessage",
{
"account": self._account,
**self._recipient_param(recipient),
"targetSentTimestamp": message_timestamp,
},
)
return DeliveryResult(success=True)
except Exception as e:
logger.error(f"Failed to pin message for {recipient}: {e}")
return DeliveryResult(success=False, error=str(e))
async def unpin_message(self, recipient: str) -> DeliveryResult:
try:
await self._rpc.call(
"unpinMessage",
{
"account": self._account,
**self._recipient_param(recipient),
},
)
return DeliveryResult(success=True)
except Exception as e:
logger.error(f"Failed to unpin message for {recipient}: {e}")
return DeliveryResult(success=False, error=str(e))
async def send_voice(
self,
recipient: str,
audio_data: bytes,
duration_ms: int = 0,
) -> DeliveryResult:
try:
params = {
"account": self._account,
**self._recipient_param(recipient),
"messageBody": "",
"attachments": [
{
"contentType": "audio/ogg",
"filename": "voice.ogg",
"data": base64.b64encode(audio_data).decode("utf-8"),
}
],
}
result = await self._rpc.call("send", params)
return DeliveryResult(
success=True,
message_id=_make_message_id(result.get("timestamp")),
)
except Exception as e:
logger.error(f"Failed to send voice to {recipient}: {e}")
return DeliveryResult(success=False, error=str(e))
def _extract_chunk_styles(styles: list[StyleRange], offset: int, chunk_len: int) -> list[StyleRange]:
chunk_end = offset + chunk_len
result: list[StyleRange] = []
for s in styles:
s_end = s.start + s.length
if s_end <= offset or s.start >= chunk_end:
continue
new_start = max(0, s.start - offset)
new_end = min(chunk_len, s_end - offset)
if new_end > new_start:
result.append(StyleRange(start=new_start, length=new_end - new_start, style=s.style))
return result