ForcePilot/backend/package/yuxi/channel/extensions/signal/monitor.py
Kris 6713dc2d0d feat(channel): 添加 Signal 渠道扩展
新增 Signal 渠道扩展,支持在 Yuxi 平台中集成 Signal 加密即时通讯渠道。

包含以下功能模块:
- client: Signal 客户端封装
- daemon: signald 守护进程管理
- config_schema: 配置模式
- send: 消息发送
- accounts: 账户管理
- account_management: 账户综合管理
- access_policy: 访问策略
- identity: 身份管理
- profiles: 用户资料
- groups: 群组管理
- format: 消息格式转换
- normalize: 消息规范化
- dedupe: 消息去重
- monitor: 渠道状态监控
- probe: 健康探测
- sse_reconnect: SSE 重连机制
2026-05-21 11:41:55 +08:00

527 lines
19 KiB
Python

import asyncio
import base64
import json
import logging
from datetime import datetime, UTC
from typing import Any
from yuxi.channel.context import ChannelContext
from yuxi.channel.extensions.signal.access_policy import (
check_dm_access,
check_group_access,
generate_pairing_code,
resolve_group_config,
)
from yuxi.channel.extensions.signal.client import SignalRpcClient, SignalSseClient
from yuxi.channel.extensions.signal.dedupe import (
SignalDedupeStore,
build_signal_dedupe_key,
)
from yuxi.channel.extensions.signal.identity import (
SignalSender,
SignalSenderPhone,
format_signal_sender_id,
resolve_signal_peer_id,
resolve_signal_recipient,
resolve_signal_sender,
)
from yuxi.channel.extensions.signal.send import send_signal_receipt
from yuxi.channel.extensions.signal.sse_reconnect import SseReconnector
from yuxi.channel.message.media_store import MediaStore
from yuxi.channel.message.models import (
GroupContext,
MessageType,
PeerInfo,
UnifiedMessage,
)
from yuxi.channel.routing.models import PeerKind
logger = logging.getLogger(__name__)
class SignalEventProcessor:
def __init__(
self,
rpc_client: SignalRpcClient | None,
sse_client: SignalSseClient | None,
account_config: dict,
dedupe_store: SignalDedupeStore,
reconnector: SseReconnector,
media_store: MediaStore | None = None,
):
self._rpc_client = rpc_client
self._sse_client = sse_client
self._account_config = account_config
self._dedupe_store = dedupe_store
self._reconnector = reconnector
self._media_store = media_store
self._message_registry: dict[str, dict] = {}
self._group_history: dict[str, list[dict]] = {}
self._pairing_store: dict[str, str] = {}
self._msg_id_counter = 0
self._receipt_event_index = 0
self._typing_event_index = 0
self._call_event_index = 0
self._remote_delete_event_index = 0
self._sync_event_index = 0
self._reaction_event_index = 0
@property
def message_registry(self) -> dict[str, dict]:
return self._message_registry
@property
def pairing_store(self) -> dict[str, str]:
return self._pairing_store
@property
def msg_id_counter(self) -> int:
return self._msg_id_counter
async def run_event_loop(self, ctx: ChannelContext) -> None:
if not self._sse_client:
return
account_phone = self._account_config.get("account", "")
while not ctx.cancel_event.is_set():
try:
async for sse_event in self._sse_client.stream_events(ctx.cancel_event):
self._reconnector.reset()
await self._process_sse_event(sse_event, ctx, account_phone)
except asyncio.CancelledError:
break
except Exception:
logger.exception("Signal SSE stream error")
await self._reconnector.wait_before_reconnect(ctx.cancel_event)
async def _process_sse_event(self, sse_event, ctx: ChannelContext, account_phone: str):
try:
payload = json.loads(sse_event.data)
except json.JSONDecodeError:
return
envelope = payload.get("envelope", payload)
source_number = envelope.get("sourceNumber") or envelope.get("source")
source_uuid = envelope.get("sourceUuid")
source_name = envelope.get("sourceName")
sender = resolve_signal_sender(source_number, source_uuid)
if not sender:
return
sender_id = format_signal_sender_id(sender)
account_uuid = self._account_config.get("account_uuid", "")
if source_uuid and account_uuid and source_uuid == account_uuid:
return
if isinstance(sender, SignalSenderPhone) and sender.raw == account_phone:
return
receipt_message = envelope.get("receiptMessage")
if receipt_message:
await self._handle_receipt_message(ctx, receipt_message, sender_id)
return
typing_message = envelope.get("typingMessage")
if typing_message:
await self._handle_typing_message(ctx, typing_message, sender_id)
return
call_message = envelope.get("callMessage")
if call_message:
await self._handle_call_message(ctx, call_message, sender_id)
return
sync_message = envelope.get("syncMessage")
if sync_message:
await self._handle_sync_message(ctx, sync_message, sender_id)
return
data_message = envelope.get("dataMessage")
edit_message = envelope.get("editMessage")
dm = edit_message.get("dataMessage") if edit_message else data_message
if not dm:
return
text = dm.get("message", "") or ""
timestamp_val = dm.get("timestamp") or envelope.get("timestamp", 0)
reaction = dm.get("reaction")
if reaction and not text.strip():
await self._handle_reaction_notification(ctx, reaction, sender_id, envelope)
return
remote_delete = dm.get("remoteDelete")
if remote_delete:
await self._handle_remote_delete(ctx, remote_delete, sender_id)
return
group_info = envelope.get("groupInfo") or dm.get("groupInfo")
group_id = group_info.get("groupId", "") if group_info else ""
is_group = bool(group_id)
if is_group:
group_cfg = resolve_group_config(self._account_config, group_id)
access_status, reason = check_group_access(
self._account_config,
sender_id,
group_allow_from=self._account_config.get("group_allow_from"),
account_key=ctx.account_id,
)
if access_status == "deny":
if group_cfg.get("ingest"):
self._append_group_history(group_id, sender_id, text)
return
if group_cfg.get("require_mention", True):
mentions = dm.get("mentions", [])
if not self._is_mentioned(mentions, account_phone, account_uuid):
if group_cfg.get("ingest"):
self._append_group_history(group_id, sender_id, text)
return
else:
access_status, reason = check_dm_access(
self._account_config,
sender_id,
pairing_store=self._pairing_store,
)
if access_status == "deny":
return
if access_status == "pairing":
await self._handle_pairing_request(ctx, sender, sender_id)
return
self._msg_id_counter += 1
dedupe_key = build_signal_dedupe_key(ctx.account_id, timestamp_val, sender_id)
if self._dedupe_store.is_duplicate(dedupe_key):
return
self._dedupe_store.mark_seen(dedupe_key)
peer_kind = PeerKind.GROUP if is_group else PeerKind.DIRECT
peer_info = PeerInfo(
kind=peer_kind,
id=resolve_signal_peer_id(sender),
display_name=source_name or sender_id,
username=sender_id,
)
group_ctx = None
if is_group and group_id:
group_ctx = GroupContext(
id=group_id,
name=group_info.get("name") if group_info else None,
)
reply_to_id = None
quote = dm.get("quote")
if quote:
reply_to_id = str(quote.get("id", ""))
attachments = dm.get("attachments", [])
media_urls: list[str] = []
image_base64: str | None = None
media_types: list[str] = []
if attachments:
for att in attachments:
att_id = att.get("id", "")
content_type = att.get("contentType", "")
filename = att.get("filename", "")
if not att_id:
continue
media_types.append(content_type)
if self._media_store and self._rpc_client:
try:
raw = await self._rpc_client.get_attachment(
att_id,
account=self._account_config.get("account"),
recipient=resolve_signal_recipient(sender),
)
if content_type.startswith("image/") and len(raw) <= 5 * 1_048_576:
image_base64 = base64.b64encode(raw).decode()
media_urls.append(f"signal-attachment:{att_id}")
else:
stored_id = await self._media_store.store(
data=raw,
content_type=content_type,
filename=filename,
source_url=f"signal-attachment:{att_id}",
)
if stored_id:
media_urls.append(f"media-store:{stored_id}")
else:
media_urls.append(f"signal-attachment:{att_id}")
except Exception:
logger.debug("Failed to download Signal attachment %s", att_id)
media_urls.append(f"signal-attachment:{att_id}")
else:
media_urls.append(f"signal-attachment:{att_id}")
try:
ts = datetime.fromtimestamp(timestamp_val / 1000, tz=UTC) if timestamp_val else datetime.now(UTC)
except Exception:
ts = datetime.now(UTC)
msg_id = str(self._msg_id_counter)
self._message_registry[msg_id] = {
"recipient": resolve_signal_recipient(sender),
"timestamp": timestamp_val,
"group_id": group_id if is_group else None,
"sender_id": sender_id,
}
message_type = MessageType.TEXT
if media_urls:
if any(t.startswith("image/") for t in media_types):
message_type = MessageType.IMAGE
elif any(t.startswith("audio/") for t in media_types):
message_type = MessageType.VOICE
else:
message_type = MessageType.FILE
metadata: dict[str, Any] = {}
expires_in = dm.get("expiresInSeconds")
if expires_in:
metadata["expires_in_seconds"] = expires_in
if dm.get("isViewOnce"):
metadata["is_view_once"] = True
sticker = dm.get("sticker")
if sticker:
metadata["sticker"] = sticker
preview = dm.get("preview")
if preview:
metadata["preview"] = preview
contact = dm.get("contact")
if contact:
metadata["contact"] = contact
if group_info and group_info.get("type"):
metadata["group_info_type"] = group_info.get("type")
msg = UnifiedMessage(
msg_id=msg_id,
channel_type="signal",
account_id=ctx.account_id,
content=text,
sender=peer_info,
message_type=message_type,
media_urls=media_urls,
image_base64=image_base64,
media_types=media_types,
group=group_ctx,
timestamp=ts,
raw_payload=payload,
reply_to_id=reply_to_id,
metadata=metadata,
)
if ctx.queue:
try:
await ctx.queue.put(msg)
except Exception:
logger.exception("Failed to enqueue Signal message")
if not is_group and self._rpc_client:
auto_start = self._account_config.get("auto_start", True)
daemon_level_receipts = auto_start and self._account_config.get("send_read_receipts", False)
program_level_receipts = self._account_config.get("send_read_receipts", True) and not daemon_level_receipts
if program_level_receipts:
try:
await send_signal_receipt(
self._rpc_client,
resolve_signal_recipient(sender),
timestamp_val,
account=self._account_config.get("account"),
)
except Exception:
logger.debug("Failed to send read receipt for DM from %s", sender_id)
def _is_mentioned(self, mentions, account_phone, account_uuid):
for m in mentions:
if m.get("number") == account_phone or m.get("uuid") == account_uuid:
return True
return False
async def _handle_receipt_message(self, ctx: ChannelContext, receipt_message, sender_id):
reaction_notifications = self._account_config.get("reaction_notifications", "own")
if reaction_notifications == "off":
return
receipts = receipt_message.get("timestamps", [])
receipt_type = receipt_message.get("type", "unknown")
if not receipts:
return
content = f"receipt {receipt_type}: message timestamps {receipts} from {sender_id}"
self._receipt_event_index += 1
msg = UnifiedMessage(
msg_id=f"receipt-{self._receipt_event_index}",
channel_type="signal",
account_id=ctx.account_id,
content=content,
sender=PeerInfo(kind=PeerKind.DIRECT, id=sender_id, display_name=sender_id),
message_type=MessageType.EVENT,
timestamp=datetime.now(UTC),
raw_payload={"receiptMessage": receipt_message},
)
if ctx.queue:
try:
await ctx.queue.put(msg)
except Exception:
pass
async def _handle_typing_message(self, ctx: ChannelContext, typing_message, sender_id):
if not self._account_config.get("typing_notifications", False):
return
action = "stopped" if typing_message.get("action") == "STOPPED" else "started"
content = f"typing {action} by {sender_id}"
self._typing_event_index += 1
msg = UnifiedMessage(
msg_id=f"typing-{self._typing_event_index}",
channel_type="signal",
account_id=ctx.account_id,
content=content,
sender=PeerInfo(kind=PeerKind.DIRECT, id=sender_id, display_name=sender_id),
message_type=MessageType.EVENT,
timestamp=datetime.now(UTC),
raw_payload={"typingMessage": typing_message},
)
if ctx.queue:
try:
await ctx.queue.put(msg)
except Exception:
pass
async def _handle_call_message(self, ctx: ChannelContext, call_message, sender_id):
self._call_event_index += 1
content = f"call event from {sender_id}"
msg = UnifiedMessage(
msg_id=f"call-{self._call_event_index}",
channel_type="signal",
account_id=ctx.account_id,
content=content,
sender=PeerInfo(kind=PeerKind.DIRECT, id=sender_id, display_name=sender_id),
message_type=MessageType.EVENT,
timestamp=datetime.now(UTC),
raw_payload={"callMessage": call_message},
)
if ctx.queue:
try:
await ctx.queue.put(msg)
except Exception:
pass
async def _handle_remote_delete(self, ctx: ChannelContext, remote_delete, sender_id):
self._remote_delete_event_index += 1
timestamp = remote_delete.get("timestamp", 0)
content = f"remote delete: message timestamp {timestamp} by {sender_id}"
msg = UnifiedMessage(
msg_id=f"remote-delete-{self._remote_delete_event_index}",
channel_type="signal",
account_id=ctx.account_id,
content=content,
sender=PeerInfo(kind=PeerKind.DIRECT, id=sender_id, display_name=sender_id),
message_type=MessageType.EVENT,
timestamp=datetime.now(UTC),
raw_payload={"remoteDelete": remote_delete},
)
if ctx.queue:
try:
await ctx.queue.put(msg)
except Exception:
pass
async def _handle_sync_message(self, ctx: ChannelContext, sync_message, sender_id):
self._sync_event_index += 1
content = f"sync message from {sender_id}"
msg = UnifiedMessage(
msg_id=f"sync-{self._sync_event_index}",
channel_type="signal",
account_id=ctx.account_id,
content=content,
sender=PeerInfo(kind=PeerKind.DIRECT, id=sender_id, display_name=sender_id),
message_type=MessageType.EVENT,
timestamp=datetime.now(UTC),
raw_payload={"syncMessage": sync_message},
)
if ctx.queue:
try:
await ctx.queue.put(msg)
except Exception:
pass
def _append_group_history(self, group_id: str, sender_id: str, text: str) -> None:
history_limit = self._account_config.get("history_limit", 50)
buf = self._group_history.setdefault(group_id, [])
buf.append({"sender": sender_id, "text": text})
if len(buf) > history_limit:
buf.pop(0)
async def _handle_pairing_request(self, ctx: ChannelContext, sender: SignalSender, sender_id: str):
code = generate_pairing_code()
self._pairing_store[sender_id] = code
recipient = resolve_signal_recipient(sender)
if self._rpc_client:
from yuxi.channel.extensions.signal.send import send_signal_message
try:
await send_signal_message(
self._rpc_client,
recipient,
f"To start chatting, please reply with this pairing code: {code}",
account=self._account_config.get("account"),
)
except Exception:
logger.exception("Failed to send pairing challenge")
logger.info("Pairing challenge sent to %s", sender_id)
async def _handle_reaction_notification(self, ctx: ChannelContext, reaction, sender_id, envelope):
reaction_notifications = self._account_config.get("reaction_notifications", "own")
if reaction_notifications == "off":
return
emoji = reaction.get("emoji", "")
is_remove = reaction.get("remove", False)
target_author = reaction.get("targetAuthorNumber") or reaction.get("targetAuthorUuid", "")
action = "removed" if is_remove else "added"
content = f"reaction {action}: {emoji} by {sender_id}"
if target_author:
content += f" on message from {target_author}"
self._reaction_event_index += 1
msg = UnifiedMessage(
msg_id=f"reaction-{self._reaction_event_index}",
channel_type="signal",
account_id=ctx.account_id,
content=content,
sender=PeerInfo(kind=PeerKind.DIRECT, id=sender_id, display_name=sender_id),
message_type=MessageType.EVENT,
timestamp=datetime.now(UTC),
raw_payload={"reaction": reaction, "envelope": envelope},
)
if ctx.queue:
try:
await ctx.queue.put(msg)
except Exception:
pass