新增了完整的Signal渠道适配器实现,包含RPC客户端、守护进程管理、安全策略、消息处理、安装配置工具等全套功能,支持通过signal-cli与Signal网络进行通信,包含账户管理、消息收发、反应处理、媒体分析、健康检查等能力。
377 lines
12 KiB
Python
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
|