新增了完整的Signal渠道适配器实现,包含RPC客户端、守护进程管理、安全策略、消息处理、安装配置工具等全套功能,支持通过signal-cli与Signal网络进行通信,包含账户管理、消息收发、反应处理、媒体分析、健康检查等能力。
429 lines
15 KiB
Python
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
|