新增 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 重连机制
527 lines
19 KiB
Python
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 |