ForcePilot/backend/package/yuxi/channels/adapters/signal/send.py
Kris b47c6126e6 refactor(signal-adapter): 整理代码结构与导入顺序,新增批量消息操作支持
1.  调整多个文件的导入顺序与格式,统一代码风格
2.  在security模块新增允许列表持久化存储逻辑
3.  新增send_sticker/send_voice/send_silent/pin/unpin等消息操作
4.  新增群组创建/删除/成员管理方法
5.  重构流式消息处理逻辑,提取为独立工具类
6.  修复配置校验与安全检查的逻辑顺序问题
7.  优化初始化流程,新增配置校验步骤
2026-05-13 16:14:06 +08:00

514 lines
18 KiB
Python

from __future__ import annotations
import asyncio
import base64
import logging
import uuid
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
from yuxi.channels.models import DeliveryResult
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
def __init__(self, rpc_client: RpcClient, account_number: str):
self._rpc = rpc_client
self._account = account_number
self._sent_message_cache: dict[str, dict] = {}
self._cache_access_order: list[str] = []
@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
def _prune_cache(self) -> None:
now = asyncio.get_event_loop().time()
expired = [k for k, v in self._sent_message_cache.items() if now - v["timestamp"] > self.CACHE_TTL]
for k in expired:
self._sent_message_cache.pop(k, None)
if k in self._cache_access_order:
self._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",
previews: list[dict] | None = None,
mentions: list[dict] | None = None,
) -> 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, previews, mentions
)
if formatted_body is not None and text_mode == "markdown":
return await self._send_formatted_text(
recipient, formatted_body, reply_to_id, chunk_mode, previews, mentions
)
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)
if previews:
params["previews"] = previews
if mentions:
params["bodyRanges"] = [
{"mentionUuid": m["user_id"], "start": m["start"], "length": m["length"]} for m in mentions
]
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",
previews: list[dict] | None = None,
mentions: list[dict] | None = None,
) -> 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)
if previews:
params["previews"] = previews
if mentions:
params["bodyRanges"] = [
{"mentionUuid": m["user_id"], "start": m["start"], "length": m["length"]} for m in mentions
]
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))
async def create_group(
self,
group_name: str,
members: list[str],
avatar_path: str | None = None,
) -> DeliveryResult:
try:
params = {
"account": self._account,
"groupName": group_name,
"members": members,
}
if avatar_path:
params["avatar"] = avatar_path
result = await self._rpc.call("createGroup", params)
group_id = result.get("groupId", "")
return DeliveryResult(
success=True,
message_id=group_id,
)
except Exception as e:
logger.error(f"Failed to create group '{group_name}': {e}")
return DeliveryResult(success=False, error=str(e))
async def delete_group(self, group_id: str) -> DeliveryResult:
try:
if not group_id.startswith(GROUP_PREFIX):
group_id = f"{GROUP_PREFIX}{group_id}"
params = {
"account": self._account,
"groupId": group_id,
}
await self._rpc.call("quitGroup", params)
return DeliveryResult(success=True)
except Exception as e:
logger.error(f"Failed to delete group {group_id}: {e}")
return DeliveryResult(success=False, error=str(e))
async def manage_members(
self,
group_id: str,
add: list[str] | None = None,
remove: list[str] | None = None,
) -> DeliveryResult:
try:
if not group_id.startswith(GROUP_PREFIX):
group_id = f"{GROUP_PREFIX}{group_id}"
if add:
params = {
"account": self._account,
"groupId": group_id,
"members": add,
}
await self._rpc.call("addMembers", params)
if remove:
params = {
"account": self._account,
"groupId": group_id,
"members": remove,
}
await self._rpc.call("removeMembers", params)
return DeliveryResult(success=True)
except Exception as e:
logger.error(f"Failed to manage members for group {group_id}: {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