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