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

242 lines
8.6 KiB
Python

from __future__ import annotations
import asyncio
import json
import logging
from collections.abc import Callable, Awaitable
from yuxi.channels.models import ChannelMessage, EventType
from yuxi.channels.adapters.signal.client import RpcClient
from yuxi.channels.adapters.signal.event_queue import OrderedEventQueue
from yuxi.channels.adapters.signal.normalize import (
build_dedup_key,
check_and_add_dedup,
check_debounce,
is_own_message,
is_sync_message,
parse_signal_delete,
parse_signal_message,
parse_signal_reaction,
)
from yuxi.channels.adapters.signal.sse_reconnect import sse_event_stream
logger = logging.getLogger(__name__)
class SSEMonitor:
def __init__(
self,
rpc_client: RpcClient,
account_number: str,
account_uuid: str | None = None,
ignore_attachments: bool = False,
ignore_stories: bool = False,
debounce_interval_ms: int = 0,
duplicate_reaction_check: bool = True,
event_queue: OrderedEventQueue | None = None,
sent_message_cache: dict[str, dict] | None = None,
):
self._rpc = rpc_client
self._account = account_number
self._account_uuid = account_uuid
self._ignore_attachments = ignore_attachments
self._ignore_stories = ignore_stories
self._debounce_interval_ms = debounce_interval_ms
self._duplicate_reaction_check = duplicate_reaction_check
self._task: asyncio.Task | None = None
self._message_handler: Callable[[ChannelMessage], Awaitable[None]] | None = None
self._running = False
self._reaction_seen: set[str] = set()
self._event_queue = event_queue
self._sent_message_cache = sent_message_cache
if self._event_queue is not None:
self._event_queue.set_handler(self._dispatch_from_queue)
def on_message(self, handler: Callable[[ChannelMessage], Awaitable[None]]) -> None:
self._message_handler = handler
async def start(self) -> None:
self._running = True
if self._event_queue is not None:
await self._event_queue.start()
url = f"{self._rpc.base_url}/api/v1/events"
self._task = asyncio.create_task(sse_event_stream(url, {"account": self._account}, self._handle_sse_event))
async def stop(self) -> None:
self._running = False
if self._task:
self._task.cancel()
try:
await self._task
except asyncio.CancelledError:
pass
if self._event_queue is not None:
await self._event_queue.stop()
async def _dispatch_from_queue(self, event: dict) -> None:
msg = self._parse_event(event)
if msg and self._message_handler:
await self._message_handler(msg)
async def _handle_sse_event(self, event_data: str) -> None:
try:
data = json.loads(event_data)
except json.JSONDecodeError:
logger.warning(f"Failed to parse SSE event: {event_data[:200]}")
return
if is_sync_message(data):
return
if self._ignore_stories and self._is_story_event(data):
return
if is_own_message(data, self._account, self._account_uuid):
return
self._check_sent_message_cache(data)
dedup_key = build_dedup_key(data, self._account)
if dedup_key and check_and_add_dedup(dedup_key):
return
conversation = data.get("dataMessage", {}).get("groupInfo", {}).get("groupId") or data.get("envelope", {}).get(
"source", ""
)
if conversation and check_debounce(conversation, self._debounce_interval_ms):
return
if self._ignore_attachments:
data = self._strip_attachments(data)
if self._is_receipt_event(data):
self._log_receipt(data)
return
if self._event_queue is not None:
ts = self._extract_timestamp(data)
await self._event_queue.push(data, timestamp=ts)
return
msg = self._parse_event(data)
if msg and self._message_handler:
await self._message_handler(msg)
def _parse_event(self, data: dict) -> ChannelMessage | None:
msg: ChannelMessage | None = None
if self._is_message_event(data):
msg = parse_signal_message(data)
elif self._is_edit_message_event(data):
msg = parse_signal_message(self._extract_edit_data(data))
if msg:
msg.event_type = EventType.MESSAGE_UPDATED
elif self._is_reaction_event(data):
if self._duplicate_reaction_check:
dedup_key = self._build_reaction_dedup_key(data)
if dedup_key and dedup_key in self._reaction_seen:
return None
if dedup_key:
self._reaction_seen.add(dedup_key)
msg = parse_signal_reaction(data)
elif self._is_delete_event(data):
msg = parse_signal_delete(data)
return msg
def _check_sent_message_cache(self, data: dict) -> None:
if not self._sent_message_cache:
return
dm = data.get("dataMessage", {})
ts = dm.get("timestamp")
if not ts:
return
for msg_id, entry in self._sent_message_cache.items():
if str(ts) in msg_id:
sender = data.get("envelope", {}).get("source", "unknown")
logger.info(
f"[Signal] Sent message delivery confirmed: ts={ts}, "
f"recipient={entry.get('recipient', 'unknown')}, sender={sender}"
)
break
@staticmethod
def _extract_timestamp(data: dict) -> int:
dm = data.get("dataMessage", {})
ts = dm.get("timestamp", 0)
if ts:
return ts
reaction = data.get("reaction", {})
ts = reaction.get("targetSentTimestamp", 0)
if ts:
return ts
delete_msg = data.get("deleteMessage", {})
ts = delete_msg.get("targetSentTimestamp", 0)
return ts
def _log_receipt(self, data: dict) -> None:
receipt = data.get("receiptMessage", {})
receipt_type = receipt.get("type", "UNKNOWN")
timestamps = receipt.get("timestamps", [])
source = data.get("envelope", {}).get("source", "unknown")
logger.debug(
f"Signal receipt: type={receipt_type}, from={source}, "
f"timestamps_count={len(timestamps)}, "
f"timestamp_range={min(timestamps) if timestamps else 'N/A'}-{max(timestamps) if timestamps else 'N/A'}"
)
@staticmethod
def _build_reaction_dedup_key(data: dict) -> str | None:
envelope = data.get("envelope", {})
reaction = data.get("reaction", {})
message_id = str(reaction.get("targetSentTimestamp", ""))
sender_id = envelope.get("source", "")
emoji = reaction.get("emoji", "")
group_info = reaction.get("groupInfo", {}) or data.get("dataMessage", {}).get("groupInfo", {}) or {}
group_id = group_info.get("groupId", "") if isinstance(group_info, dict) else ""
if not message_id or not sender_id:
return None
return f"reaction:{message_id}:{sender_id}:{emoji}:{group_id}"
@staticmethod
def _is_message_event(data: dict) -> bool:
return "envelope" in data and "dataMessage" in data
@staticmethod
def _is_edit_message_event(data: dict) -> bool:
envelope = data.get("envelope", {})
return "editMessage" in envelope and "dataMessage" in envelope.get("editMessage", {})
@staticmethod
def _extract_edit_data(data: dict) -> dict:
edit_msg = data.get("envelope", {}).get("editMessage", {})
return {
"envelope": data.get("envelope", {}),
"dataMessage": edit_msg.get("dataMessage", {}),
}
@staticmethod
def _is_reaction_event(data: dict) -> bool:
return "envelope" in data and "reaction" in data
@staticmethod
def _is_delete_event(data: dict) -> bool:
return "envelope" in data and "deleteMessage" in data
@staticmethod
def _is_receipt_event(data: dict) -> bool:
return "envelope" in data and "receiptMessage" in data
@staticmethod
def _is_story_event(data: dict) -> bool:
return "envelope" in data and "storyMessage" in data
@staticmethod
def _strip_attachments(data: dict) -> dict:
data_msg = data.get("dataMessage")
if isinstance(data_msg, dict) and "attachments" in data_msg:
data_msg = {**data_msg, "attachments": []}
data = {**data, "dataMessage": data_msg}
return data