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

377 lines
12 KiB
Python

from __future__ import annotations
import re
import time
from datetime import datetime, UTC
from yuxi.channels.models import (
Attachment,
ChannelIdentity,
ChannelMessage,
ChannelType,
ChatType,
EventType,
MentionsInfo,
MessageType,
)
E164_PATTERN = re.compile(r"^\+\d{7,15}$")
UUID_PREFIX = "uuid:"
GROUP_PREFIX = "group:"
DEDUP_TTL = 60
_dedup_store: dict[str, float] = {}
def is_own_message(data: dict, account_number: str, account_uuid: str | None = None) -> bool:
envelope = data.get("envelope", {})
source = envelope.get("source", "")
if normalize_target(source) == normalize_target(account_number):
return True
if account_uuid:
source_uuid = envelope.get("sourceUuid", "")
if source_uuid and source_uuid == account_uuid:
return True
return False
def is_sync_message(data: dict) -> bool:
return "syncMessage" in data
DEBOUNCE_TTL = 5
_debounce_store: dict[str, float] = {}
def check_debounce(conversation_key: str, interval_ms: int = 0) -> bool:
if interval_ms <= 0:
return False
now = time.monotonic()
_prune_debounce_store(now)
if conversation_key in _debounce_store:
last = _debounce_store[conversation_key]
if (now - last) * 1000 < interval_ms:
return True
_debounce_store[conversation_key] = now
return False
def _prune_debounce_store(now: float) -> None:
expired = [k for k, ts in _debounce_store.items() if now - ts > DEBOUNCE_TTL]
for k in expired:
_debounce_store.pop(k, None)
def build_dedup_key(data: dict, account_id: str) -> str | None:
envelope = data.get("envelope", {})
source = envelope.get("source", "")
timestamp = (
data.get("dataMessage", {}).get("timestamp")
or data.get("reaction", {}).get("targetSentTimestamp")
or data.get("deleteMessage", {}).get("targetSentTimestamp")
or data.get("receiptMessage", {}).get("timestamps", [None])[0]
)
if not source or not timestamp:
return None
group_id = data.get("dataMessage", {}).get("groupInfo", {}).get("groupId") or ""
conversation = group_id or source
return f"signal:{account_id}:{conversation}:{source}:{timestamp}"
def check_and_add_dedup(key: str) -> bool:
now = time.monotonic()
_prune_dedup_store(now)
if key in _dedup_store:
return True
_dedup_store[key] = now
return False
def _prune_dedup_store(now: float) -> None:
expired = [k for k, ts in _dedup_store.items() if now - ts > DEDUP_TTL]
for k in expired:
_dedup_store.pop(k, None)
def normalize_target(raw: str) -> str:
stripped = raw.strip()
if stripped.startswith(GROUP_PREFIX):
return stripped
if stripped.startswith(UUID_PREFIX):
return stripped
if E164_PATTERN.match(stripped):
return stripped
digits = re.sub(r"[^\d]", "", stripped)
if 7 <= len(digits) <= 15:
return f"+{digits}"
raise ValueError(f"Unable to normalize Signal target: {raw}")
def normalize_e164(raw: str) -> str:
stripped = raw.strip().removeprefix("+")
return re.sub(r"[^\d]", "", stripped)
def parse_signal_message(data: dict, channel_id: str = "signal") -> ChannelMessage | None:
envelope = data.get("envelope", {})
data_msg = data.get("dataMessage", {})
if not data_msg:
return None
source = envelope.get("source", "unknown")
source_name = envelope.get("sourceName", "")
group_info = data_msg.get("groupInfo", {})
group_id = group_info.get("groupId")
chat_id = f"group:{group_id}" if group_id else source
timestamp_raw = data_msg.get("timestamp", 0)
ts = datetime.fromtimestamp(timestamp_raw / 1000.0, tz=UTC) if timestamp_raw else datetime.now(tz=UTC)
content = data_msg.get("message", "") or data_msg.get("body", "")
chat_type = ChatType.GROUP if group_id else ChatType.DIRECT
message_type = MessageType.TEXT
attachments: list[Attachment] = []
if data_msg.get("attachments"):
raw_attachments = data_msg["attachments"] if isinstance(data_msg["attachments"], list) else []
for att in raw_attachments:
content_type = att.get("contentType", "")
attachments.append(
Attachment(
type=_attachment_type(content_type),
file_id=att.get("id", ""),
filename=att.get("filename"),
mime_type=content_type,
size_bytes=att.get("size"),
metadata={"digest": att.get("digest", "")},
)
)
if attachments:
message_type = _attachment_message_type(attachments[0].mime_type or "")
reply_to_id = None
quote = data_msg.get("quote")
if quote:
reply_to_id = str(quote.get("id", ""))
mentions = _extract_mentions(data_msg, content)
extracted_urls = _extract_urls(data_msg, content)
metadata: dict = {}
if data_msg.get("expiresInSeconds"):
metadata["expires_in_seconds"] = data_msg["expiresInSeconds"]
if data_msg.get("viewOnce"):
metadata["view_once"] = True
if data_msg.get("sticker"):
metadata["sticker_pack_id"] = data_msg["sticker"].get("packId", "")
metadata["sticker_id"] = data_msg["sticker"].get("stickerId", "")
if data_msg.get("forwarded"):
metadata["forwarded"] = True
if data_msg.get("contacts"):
contacts = data_msg["contacts"]
metadata["contacts"] = contacts if isinstance(contacts, list) else [contacts]
if data_msg.get("location"):
loc = data_msg["location"]
metadata["location"] = {
"latitude": loc.get("latitude"),
"longitude": loc.get("longitude"),
"label": loc.get("label", ""),
}
if source_name:
metadata["sender_display_name"] = source_name
if data_msg.get("sticker"):
message_type = MessageType.STICKER
elif data_msg.get("location"):
message_type = MessageType.LOCATION
elif attachments:
message_type = _attachment_message_type(attachments[0].mime_type or "")
return ChannelMessage(
identity=ChannelIdentity(
channel_id=channel_id,
channel_type=ChannelType.SIGNAL,
channel_user_id=source,
channel_chat_id=chat_id,
channel_message_id=str(timestamp_raw),
),
event_type=EventType.MESSAGE_RECEIVED,
message_type=message_type,
chat_type=chat_type,
content=content,
reply_to_message_id=reply_to_id,
attachments=attachments,
mentions=mentions,
extracted_urls=extracted_urls,
metadata=metadata,
timestamp=ts,
)
def parse_signal_reaction(data: dict, channel_id: str = "signal") -> ChannelMessage | None:
envelope = data.get("envelope", {})
reaction = data.get("reaction", {})
if not reaction:
return None
source = envelope.get("source", "unknown")
target_author = reaction.get("targetAuthor", source)
emoji = reaction.get("emoji", "")
is_remove = reaction.get("remove", False)
target_timestamp = reaction.get("targetSentTimestamp", 0)
group_info = reaction.get("groupInfo", {})
group_id = group_info.get("groupId")
chat_id = f"group:{group_id}" if group_id else source
return ChannelMessage(
identity=ChannelIdentity(
channel_id=channel_id,
channel_type=ChannelType.SIGNAL,
channel_user_id=source,
channel_chat_id=chat_id,
channel_message_id=str(target_timestamp),
),
event_type=EventType.MESSAGE_UPDATED,
message_type=MessageType.TEXT,
chat_type=ChatType.GROUP if group_id else ChatType.DIRECT,
content="(reaction_removed)" if is_remove else f"(reacted: {emoji})",
metadata={
"reaction_emoji": emoji,
"reaction_removed": is_remove,
"reaction_target_author": target_author,
},
)
def parse_signal_receipt(data: dict, channel_id: str = "signal") -> ChannelMessage | None:
envelope = data.get("envelope", {})
receipt = data.get("receiptMessage", {})
if not receipt:
return None
source = envelope.get("source", "unknown")
receipt_type = receipt.get("type", "UNKNOWN")
timestamps = receipt.get("timestamps", [])
return ChannelMessage(
identity=ChannelIdentity(
channel_id=channel_id,
channel_type=ChannelType.SIGNAL,
channel_user_id=source,
channel_chat_id=source,
),
event_type=EventType.READ_RECEIPT,
message_type=MessageType.TEXT,
chat_type=ChatType.DIRECT,
content=f"(receipt: {receipt_type})",
metadata={
"receipt_type": receipt_type,
"receipt_timestamps": timestamps,
},
)
def _attachment_type(content_type: str) -> str:
if content_type.startswith("image/"):
return "image"
if content_type.startswith("video/"):
return "video"
if content_type.startswith("audio/"):
return "audio"
return "file"
def _attachment_message_type(mime_type: str) -> MessageType:
if mime_type.startswith("image/"):
return MessageType.IMAGE
if mime_type.startswith("video/"):
return MessageType.VIDEO
if mime_type.startswith("audio/"):
return MessageType.AUDIO
return MessageType.FILE
URL_PATTERN = re.compile(r"https?://\S+")
def _extract_mentions(data_msg: dict, content: str, account_uuid: str | None = None) -> MentionsInfo | None:
body_ranges = data_msg.get("bodyRanges")
if not body_ranges or not isinstance(body_ranges, list):
return None
mentioned_ids: list[str] = []
is_bot_mentioned = False
for br in body_ranges:
mention_uuid = br.get("mentionUuid")
if mention_uuid:
mentioned_ids.append(mention_uuid)
if account_uuid and mention_uuid == account_uuid:
is_bot_mentioned = True
elif br.get("startsWith") and content:
start = br.get("start", 0)
length = br.get("length", 0)
if 0 <= start < len(content):
mention_text = content[start : start + length]
mentioned_ids.append(mention_text)
if not mentioned_ids:
return None
return MentionsInfo(
mentioned_user_ids=mentioned_ids,
is_bot_mentioned=is_bot_mentioned,
raw_text=content,
)
def _extract_urls(data_msg: dict, content: str) -> list[str]:
previews = data_msg.get("previews")
if previews and isinstance(previews, list):
return [p.get("url", "") for p in previews if p.get("url")]
return URL_PATTERN.findall(content) if content else []
def parse_signal_delete(data: dict, channel_id: str = "signal") -> ChannelMessage | None:
envelope = data.get("envelope", {})
delete_msg = data.get("deleteMessage")
if not delete_msg:
return None
source = envelope.get("source", "unknown")
target_timestamp = delete_msg.get("targetSentTimestamp", 0)
group_info = delete_msg.get("groupInfo", {})
group_id = group_info.get("groupId")
chat_id = f"group:{group_id}" if group_id else source
return ChannelMessage(
identity=ChannelIdentity(
channel_id=channel_id,
channel_type=ChannelType.SIGNAL,
channel_user_id=source,
channel_chat_id=chat_id,
channel_message_id=str(target_timestamp),
),
event_type=EventType.MESSAGE_DELETED,
message_type=MessageType.TEXT,
chat_type=ChatType.GROUP if group_id else ChatType.DIRECT,
content="(message deleted)",
metadata={"deleted_timestamp": target_timestamp},
)
def looks_like_signal_target_id(raw: str) -> bool:
stripped = raw.strip()
if stripped.startswith(GROUP_PREFIX) or stripped.startswith(UUID_PREFIX):
return True
if E164_PATTERN.match(stripped):
return True
digits = re.sub(r"[^\d]", "", stripped)
return 7 <= len(digits) <= 15