Files
hermes-agent/gateway/platforms/signal.py
T

1253 lines
58 KiB
Python

"""Signal messenger platform adapter.
Connects to a signal-cli daemon running in HTTP mode. Inbound messages arrive
via SSE streaming; outbound messages and actions use JSON-RPC 2.0 over HTTP.
Requires:
- signal-cli installed and running: signal-cli daemon --http 127.0.0.1:8080
- SIGNAL_HTTP_URL and SIGNAL_ACCOUNT environment variables set
"""
import asyncio
import base64
import itertools
import json
import logging
import os
import random
import shutil
import subprocess
import tempfile
import time
import uuid
from collections import OrderedDict
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple
from urllib.parse import quote, unquote
import httpx
from gateway.config import Platform, PlatformConfig
from gateway.platforms.base import (
BasePlatformAdapter,
MessageEvent,
MessageType,
ProcessingOutcome,
SendResult,
cache_image_from_bytes,
cache_audio_from_bytes,
cache_document_from_bytes,
cache_image_from_url,
utf16_len,
)
from gateway.platforms.helpers import redact_phone
from gateway.platforms.media_cache import mime_for_ext
from tools.audio_container import CONTAINER_TO_EXT, sniff_container
from gateway.platforms.signal_format import markdown_to_signal
from gateway.platforms.signal_rate_limit import (
SIGNAL_BATCH_PACING_NOTICE_THRESHOLD,
SIGNAL_MAX_ATTACHMENTS_PER_MSG,
SIGNAL_RATE_LIMIT_MAX_ATTEMPTS,
SignalRateLimitError,
_extract_retry_after_seconds,
_format_wait,
_is_signal_rate_limit_error,
_signal_send_timeout,
get_scheduler,
)
from gateway.platforms._shared import get_scoped_secret as _sig_secret
logger = logging.getLogger(__name__)
SIGNAL_MAX_ATTACHMENT_SIZE = 100 * 1024 * 1024 # 100 MB
MAX_MESSAGE_LENGTH = 8000 # Signal message size limit
SSE_RETRY_DELAY_INITIAL = 2.0
SSE_RETRY_DELAY_MAX = 60.0
HEALTH_CHECK_INTERVAL = 30.0 # seconds between health checks
HEALTH_CHECK_STALE_THRESHOLD = 120.0 # seconds without SSE activity before concern
# Magic-byte prefixes checked before delegating to the shared audio/AV sniffer.
_MAGIC_EXTENSIONS = (
(b"\x89PNG", ".png"),
(b"\xff\xd8", ".jpg"),
(b"GIF8", ".gif"),
(b"%PDF", ".pdf"),
)
_MEDIA_TYPE_BY_MIME_PREFIX = (
("audio/", MessageType.VOICE),
("image/", MessageType.PHOTO),
("video/", MessageType.VIDEO),
)
_OUTCOME_REACTION = {ProcessingOutcome.SUCCESS: "✅", ProcessingOutcome.FAILURE: "❌"}
_QUOTE_AUTHOR_KEYS = (
"author", "authorNumber", "authorUuid", "authorAci", "authorServiceId", "authorServiceIdString",
)
def _parse_comma_list(value: str) -> List[str]:
"""Split a comma-separated string into a list, stripping whitespace."""
return [v.strip() for v in value.split(",") if v.strip()]
def _guess_extension(data: bytes) -> str:
"""Guess file extension from magic bytes.
WEBP is claimed before the shared audio/AV sniffer (it shares RIFF with WAVE);
tools/audio_container.py owns the MP3-vs-ADTS-AAC sync-word disambiguation.
"""
for magic, ext in _MAGIC_EXTENSIONS:
if data.startswith(magic):
return ext
if len(data) >= 12 and data[:4] == b"RIFF" and data[8:12] == b"WEBP":
return ".webp"
container = sniff_container(data)
if container is not None:
return CONTAINER_TO_EXT[container]
if data[:2] == b"PK":
return ".zip"
return ".bin"
def _is_image_ext(ext: str) -> bool:
return ext.lower() in {".jpg", ".jpeg", ".png", ".gif", ".webp"}
def _is_audio_ext(ext: str) -> bool:
return ext.lower() in {".mp3", ".wav", ".ogg", ".m4a", ".aac"}
def _ext_to_mime(ext: str) -> str:
"""Map file extension to MIME type (shared table matches Signal's historical map)."""
return mime_for_ext(ext, fallback="application/octet-stream")
def _remux_aac_to_m4a(aac_data: bytes) -> Optional[Tuple[bytes, str]]:
"""Losslessly remux raw ADTS AAC (Android voice notes, rejected by most STT APIs) to .m4a.
Returns ``(m4a_bytes, ".m4a")`` or ``None`` when ffmpeg is missing or fails —
callers must then pass the input through unchanged.
"""
# Fall back to common Homebrew/local prefixes on macOS dev hosts.
ffmpeg = shutil.which("ffmpeg") or next(
(p for p in ("/opt/homebrew/bin/ffmpeg", "/usr/local/bin/ffmpeg")
if os.path.isfile(p) and os.access(p, os.X_OK)), None,
)
if not ffmpeg:
logger.debug("Signal: ffmpeg not found, skipping AAC→M4A remux")
return None
try:
with tempfile.NamedTemporaryFile(suffix=".aac", delete=False) as src:
src.write(aac_data)
src_path = src.name
dst_path = src_path[:-4] + ".m4a"
try:
proc = subprocess.run(
[ffmpeg, "-y", "-loglevel", "error", "-i", src_path,
"-c:a", "copy", "-movflags", "+faststart", dst_path],
capture_output=True, timeout=10,
)
if proc.returncode != 0:
logger.warning(
"Signal: AAC→M4A remux failed (ffmpeg exit %d): %s",
proc.returncode, proc.stderr.decode("utf-8", "replace")[:300],
)
return None
with open(dst_path, "rb") as f:
return f.read(), ".m4a"
finally:
for p in (src_path, dst_path):
try:
os.unlink(p)
except OSError:
pass
except subprocess.TimeoutExpired:
logger.warning("Signal: AAC→M4A remux timed out (>10s)")
return None
except Exception:
logger.exception("Signal: AAC→M4A remux error")
return None
def _render_mentions(text: str, mentions: list) -> str:
"""Replace Signal mention placeholders (\\uFFFC + out-of-band start/length/number
metadata) with readable @identifiers. Replace from the end so indices hold."""
if not mentions or "\uFFFC" not in text:
return text
for mention in sorted(mentions, key=lambda m: m.get("start", 0), reverse=True):
start = mention.get("start", 0)
length = mention.get("length", 1)
identifier = mention.get("number") or mention.get("uuid") or "user"
text = text[:start] + f"@{identifier}" + text[start + length:]
return text
def _is_signal_service_id(value: str) -> bool:
"""Return True if *value* already looks like a Signal service identifier."""
if not value:
return False
if value.startswith("PNI:") or value.startswith("u:"):
return True
try:
uuid.UUID(value)
return True
except (ValueError, AttributeError, TypeError):
return False
def _looks_like_e164_number(value: str) -> bool:
"""Return True for a plausible E.164 phone number."""
if not value or not value.startswith("+"):
return False
digits = value[1:]
return digits.isdigit() and 7 <= len(digits) <= 15
def check_signal_requirements() -> bool:
"""Check if Signal runtime dependencies are available."""
return True
def validate_signal_config(config: PlatformConfig) -> bool:
"""Check if Signal has enough config to connect."""
extra = getattr(config, "extra", {}) or {}
http_url = (extra.get("http_url", "") or os.getenv("SIGNAL_HTTP_URL", "")).strip()
account = (extra.get("account", "") or os.getenv("SIGNAL_ACCOUNT", "")).strip()
return bool(http_url and account)
class SignalAdapter(BasePlatformAdapter):
"""Signal messenger adapter using signal-cli HTTP daemon."""
platform = Platform.SIGNAL
MAX_MESSAGE_LENGTH = MAX_MESSAGE_LENGTH
splits_long_messages = True # send() chunks after markdown → Signal formatting conversion
# Signal has no real edit API; declaring it lets streaming suppress the visible
# cursor instead of leaving a stale tofu square behind when edits fail.
SUPPORTS_MESSAGE_EDITING = False
def __init__(self, config: PlatformConfig):
super().__init__(config, Platform.SIGNAL)
extra = config.extra or {}
self.http_url = extra.get("http_url", "http://127.0.0.1:8080").rstrip("/")
self.account = extra.get("account", "")
self.ignore_stories = extra.get("ignore_stories", True)
# Allowlists are per-profile: scoped reads so secondary profiles don't inherit the
# default profile's list. Group policy derives from the group allowlist's presence.
self.group_allow_from = set(_parse_comma_list(_sig_secret("SIGNAL_GROUP_ALLOWED_USERS", "")))
# Mention filter — only respond in groups when the bot account is @mentioned.
_rm_cfg = extra.get("require_mention")
if _rm_cfg is not None:
self.require_mention = bool(_rm_cfg)
else:
self.require_mention = os.getenv("SIGNAL_REQUIRE_MENTION", "false").lower() in ("true", "1", "yes", "on")
# DM allowlist mirrors run.py's SIGNAL_ALLOWED_USERS check so the reaction hooks
# (which fire before run.py's auth gate) can skip unauthorized senders. "*" = open.
self.dm_allow_from = set(_parse_comma_list(_sig_secret("SIGNAL_ALLOWED_USERS", "*")))
self.client: Optional[httpx.AsyncClient] = None
self._sse_task: Optional[asyncio.Task] = None
self._health_monitor_task: Optional[asyncio.Task] = None
self._typing_tasks: Dict[str, asyncio.Task] = {}
# Per-chat typing-indicator backoff: when signal-cli reports NETWORK_FAILURE,
# base.py's _keep_typing loop would otherwise hammer sendTyping every ~2s.
self._typing_failures: Dict[str, int] = {}
self._typing_skip_until: Dict[str, float] = {}
self._running = False
self._last_sse_activity = 0.0
self._sse_response: Optional[httpx.Response] = None
self._account_normalized = self.account.strip()
# Recently sent timestamps filter echo-backs (Note to Self / linked-device group
# sync-sents). LRU + TTL so a still-pending echo in a chatty group isn't evicted
# just because many outbounds happened; the cap only guards runaway producers.
self._recent_sent_timestamps: "OrderedDict[int, float]" = OrderedDict()
self._max_recent_timestamps = 512
self._recent_sent_ttl_seconds = 300.0
# Separate FIFO cache of outbound timestamps: Signal quote.id is the quoted
# message's timestamp, so replies to this bot are recognised even after the
# self-sync echo above was consumed.
self._sent_message_timestamps: "OrderedDict[str, None]" = OrderedDict()
self._max_sent_message_timestamps = 500
# Best-effort number↔ACI/PNI UUID mapping so outbound sends can upgrade a
# phone number to the UUID signal-cli prefers.
self._recipient_uuid_by_number: Dict[str, str] = {}
self._recipient_number_by_uuid: Dict[str, str] = {}
self._recipient_cache_lock = asyncio.Lock()
logger.info("Signal adapter initialized: url=%s account=%s groups=%s",
self.http_url, redact_phone(self.account),
"enabled" if self.group_allow_from else "disabled")
async def connect(self, *, is_reconnect: bool = False) -> bool:
"""Connect to signal-cli daemon and start SSE listener."""
if not self.http_url or not self.account:
logger.error("Signal: SIGNAL_HTTP_URL and SIGNAL_ACCOUNT are required")
return False
# Scoped lock prevents duplicate Signal listeners for the same phone.
lock_acquired = False
try:
if not self._acquire_platform_lock('signal-phone', self.account, 'Signal account'):
return False
lock_acquired = True
except Exception as e:
logger.warning("Signal: Could not acquire phone lock (non-fatal): %s", e)
# Tighter keepalive so idle CLOSE_WAIT drains promptly.
from gateway.platforms._http_client_limits import platform_httpx_limits
self.client = httpx.AsyncClient(timeout=30.0, limits=platform_httpx_limits())
try:
try:
resp = await self.client.get(f"{self.http_url}/api/v1/check", timeout=10.0)
if resp.status_code != 200:
logger.error("Signal: health check failed (status %d)", resp.status_code)
return False
except Exception as e:
logger.error("Signal: cannot reach signal-cli at %s: %s", self.http_url, e)
return False
self._running = True
self._last_sse_activity = time.time()
self._sse_task = asyncio.create_task(self._sse_listener())
self._health_monitor_task = asyncio.create_task(self._health_monitor())
logger.info("Signal: connected to %s", self.http_url)
# Plugin-registered native handlers (ctx.register_platform_handler).
self._wire_plugin_handlers(None)
return True
finally:
if not self._running:
if self.client:
await self.client.aclose()
self.client = None
if lock_acquired:
self._release_platform_lock()
@staticmethod
async def _cancel_task(task: Optional[asyncio.Task]) -> None:
if task:
task.cancel()
try:
await task
except asyncio.CancelledError:
pass
async def disconnect(self) -> None:
"""Stop SSE listener and clean up."""
self._running = False
for task in (self._sse_task, self._health_monitor_task):
await self._cancel_task(task)
for task in self._typing_tasks.values():
task.cancel()
self._typing_tasks.clear()
if self.client:
await self.client.aclose()
self.client = None
self._release_platform_lock()
logger.info("Signal: disconnected")
async def _sse_listener(self) -> None:
"""Listen for SSE events from signal-cli daemon."""
url = f"{self.http_url}/api/v1/events?account={quote(self.account, safe='')}"
backoff = SSE_RETRY_DELAY_INITIAL
while self._running:
try:
logger.debug("Signal SSE: connecting to %s", url)
async with self.client.stream(
"GET", url, headers={"Accept": "text/event-stream"}, timeout=None,
) as response:
self._sse_response = response
backoff = SSE_RETRY_DELAY_INITIAL # Reset on successful connection
self._last_sse_activity = time.time()
logger.info("Signal SSE: connected")
buffer = ""
async for chunk in response.aiter_text():
if not self._running:
break
buffer += chunk
while "\n" in buffer:
line, buffer = buffer.split("\n", 1)
line = line.strip()
if not line:
continue
# Keepalive comments (":") prove the connection is alive —
# count them as activity so the health monitor stays quiet.
if line.startswith(":"):
self._last_sse_activity = time.time()
continue
if line.startswith("data:"):
data_str = line[5:].strip()
if not data_str:
continue
self._last_sse_activity = time.time()
try:
data = json.loads(data_str)
await self._handle_envelope(data)
except json.JSONDecodeError:
logger.debug("Signal SSE: invalid JSON: %s", data_str[:100])
except Exception:
logger.exception("Signal SSE: error handling event")
except asyncio.CancelledError:
break
except httpx.HTTPError as e:
if self._running:
logger.warning("Signal SSE: HTTP error: %s (reconnecting in %.0fs)", e, backoff)
except Exception as e:
if self._running:
logger.warning("Signal SSE: error: %s (reconnecting in %.0fs)", e, backoff)
if self._running:
# 20% jitter prevents thundering herd on reconnection
jitter = backoff * 0.2 * random.random()
await asyncio.sleep(backoff + jitter)
backoff = min(backoff * 2, SSE_RETRY_DELAY_MAX)
self._sse_response = None
async def _health_monitor(self) -> None:
"""Monitor SSE connection health and force reconnect if stale."""
while self._running:
await asyncio.sleep(HEALTH_CHECK_INTERVAL)
if not self._running:
break
elapsed = time.time() - self._last_sse_activity
if elapsed > HEALTH_CHECK_STALE_THRESHOLD:
logger.warning("Signal: SSE idle for %.0fs, checking daemon health", elapsed)
try:
resp = await self.client.get(f"{self.http_url}/api/v1/check", timeout=10.0)
if resp.status_code == 200:
# Daemon alive but SSE quiet — reset activity to avoid repeated warnings
self._last_sse_activity = time.time()
logger.debug("Signal: daemon healthy, SSE idle")
else:
logger.warning("Signal: health check failed (%d), forcing reconnect", resp.status_code)
self._force_reconnect()
except Exception as e:
logger.warning("Signal: health check error: %s, forcing reconnect", e)
self._force_reconnect()
def _force_reconnect(self) -> None:
"""Force SSE reconnection by closing the current response."""
if self._sse_response and not self._sse_response.is_stream_consumed:
try:
task = asyncio.create_task(self._sse_response.aclose())
self._background_tasks.add(task)
task.add_done_callback(self._background_tasks.discard)
except Exception:
pass
self._sse_response = None
def _unwrap_sync_message(self, envelope_data: dict) -> Optional[dict]:
"""Promote a genuine "Note to Self" / group sync-sent to a dataMessage envelope;
None for other sync events (read receipts, typing, our own outbound echoes)."""
sync_msg = envelope_data.get("syncMessage")
if not sync_msg or not isinstance(sync_msg, dict):
return None
sent_msg = sync_msg.get("sentMessage")
if not sent_msg or not isinstance(sent_msg, dict):
return None
dest = sent_msg.get("destinationNumber") or sent_msg.get("destination")
sent_msg_group_info = sent_msg.get("groupInfo") or {}
sent_msg_group_id = sent_msg_group_info.get("groupId") if sent_msg_group_info else None
if dest != self._account_normalized and not sent_msg_group_id:
return None
if self._consume_sent_timestamp(sent_msg.get("timestamp")):
return None # echo of our own outbound reply
return {**envelope_data, "dataMessage": sent_msg}
def _apply_group_mention_rules(self, text: str, data_message: dict) -> Tuple[bool, str]:
"""Gate on require_mention (False = drop) and strip the bot's own @mention.
The self-mention is stripped from every group message so the agent doesn't
read "@+155****4567 say hello" as a directive to contact that number.
"""
account_norm = self._account_normalized
if self.require_mention:
mentioned_in_text = account_norm and (f"@{account_norm}" in (text or ""))
mentioned_in_metadata = any(
m.get("number") == account_norm or m.get("uuid") == account_norm
for m in (data_message.get("mentions") or [])
)
if not mentioned_in_text and not mentioned_in_metadata:
logger.debug("Signal: ignoring group message (require_mention=true, bot not mentioned)")
return False, text
if text and account_norm:
text = text.replace(f"@{account_norm}", "")
bot_uuid = self._recipient_uuid_by_number.get(account_norm)
if bot_uuid:
text = text.replace(f"@{bot_uuid}", "")
# Collapse only the doubled space the removal introduced; intentional
# newlines in multi-line messages are preserved.
text = text.replace(" ", " ").strip()
return True, text
async def _collect_attachments(self, attachments_data: list) -> Tuple[List[str], List[str]]:
"""Fetch + cache inbound attachments; returns (media_urls, media_types)."""
media_urls: List[str] = []
media_types: List[str] = []
for att in attachments_data:
att_id = att.get("id")
att_size = att.get("size", 0)
if not att_id:
continue
if att_size > SIGNAL_MAX_ATTACHMENT_SIZE:
logger.warning("Signal: attachment too large (%d bytes), skipping", att_size)
continue
try:
cached_path, ext = await self._fetch_attachment(att_id)
if cached_path:
media_urls.append(cached_path)
media_types.append(att.get("contentType") or _ext_to_mime(ext))
except Exception:
logger.exception("Signal: failed to fetch attachment %s", att_id)
return media_urls, media_types
async def _handle_envelope(self, envelope: dict) -> None:
"""Process an incoming signal-cli envelope."""
envelope_data = envelope.get("envelope", envelope)
is_note_to_self = False
if "syncMessage" in envelope_data:
envelope_data = self._unwrap_sync_message(envelope_data)
if envelope_data is None:
return
is_note_to_self = True
sender = (
envelope_data.get("sourceNumber")
or envelope_data.get("sourceUuid")
or envelope_data.get("source")
)
sender_name = envelope_data.get("sourceName", "")
sender_uuid = envelope_data.get("sourceUuid", "")
self._remember_recipient_identifiers(sender, sender_uuid)
if not sender:
logger.debug("Signal: ignoring envelope with no sender")
return
# Self-message filtering prevents reply loops (Note to Self is allowed)
if self._account_normalized and sender == self._account_normalized and not is_note_to_self:
return
if self.ignore_stories and envelope_data.get("storyMessage"):
return
# Edited messages carry their updated dataMessage inside editMessage
data_message = (
envelope_data.get("dataMessage")
or (envelope_data.get("editMessage") or {}).get("dataMessage")
)
if not data_message:
return
group_info = data_message.get("groupInfo")
group_id = group_info.get("groupId") if group_info else None
is_group = bool(group_id)
# Group policy derives from SIGNAL_GROUP_ALLOWED_USERS: unset → groups disabled;
# IDs → only those groups; "*" → all. DM auth is run.py's (_is_user_authorized).
if is_group:
if not self.group_allow_from:
logger.debug("Signal: ignoring group message (no SIGNAL_GROUP_ALLOWED_USERS)")
return
if "*" not in self.group_allow_from and group_id not in self.group_allow_from:
logger.debug("Signal: group %s not in allowlist", group_id[:8] if group_id else "?")
return
chat_id = sender if not is_group else f"group:{group_id}"
chat_type = "group" if is_group else "dm"
text = data_message.get("message", "")
mentions = data_message.get("mentions", [])
if text and mentions:
text = _render_mentions(text, mentions)
if is_group:
mentioned, text = self._apply_group_mention_rules(text, data_message)
if not mentioned:
return
# Signal's quote.id is the quoted message's timestamp; quote.author the quoted
# sender. Preserve both so the gateway can tell the agent which message the
# user replied to.
quote_data = data_message.get("quote") or {}
reply_to_id = str(quote_data.get("id")) if quote_data.get("id") else None
reply_to_author = self._extract_quote_author(quote_data)
attachments_data = data_message.get("attachments", [])
media_urls: List[str] = []
media_types: List[str] = []
if attachments_data and not getattr(self, "ignore_attachments", False):
media_urls, media_types = await self._collect_attachments(attachments_data)
# Skip contentless envelopes (profile key updates, empty messages) that still
# carry a dataMessage wrapper — otherwise msg='' triggers a full agent turn.
if (not text or not text.strip()) and not media_urls:
logger.debug(
"Signal: skipping contentless envelope from %s (%d attachments)",
redact_phone(sender), len(media_urls) if media_urls else 0,
)
return
source = self.build_source(
chat_id=chat_id,
chat_name=group_info.get("groupName") if group_info else sender_name,
chat_type=chat_type,
user_id=sender,
user_name=sender_name or sender,
user_id_alt=sender_uuid if sender_uuid else None,
chat_id_alt=group_id if is_group else None,
)
# First matching MIME prefix wins; everything else (application/*, text/*,
# unknown) is a DOCUMENT so run.py's document-context injection surfaces the
# cached path to the agent.
msg_type = MessageType.TEXT
if media_types:
msg_type = next(
(mt for prefix, mt in _MEDIA_TYPE_BY_MIME_PREFIX
if any(m.startswith(prefix) for m in media_types)),
MessageType.DOCUMENT,
)
ts_ms = envelope_data.get("timestamp", 0) # milliseconds since epoch
timestamp = datetime.now(tz=timezone.utc)
if ts_ms:
try:
timestamp = datetime.fromtimestamp(ts_ms / 1000, tz=timezone.utc)
except (ValueError, OSError):
pass
# raw_message keeps sender + timestamp_ms so the processing hooks can build
# sendReaction targets.
event = MessageEvent(
source=source,
text=text or "",
message_type=msg_type,
media_urls=media_urls,
media_types=media_types,
timestamp=timestamp,
raw_message={
"sender": sender,
"timestamp_ms": ts_ms,
"quote": quote_data if quote_data else None,
},
reply_to_message_id=reply_to_id,
reply_to_text=quote_data.get("text"),
reply_to_author_id=reply_to_author,
reply_to_author_name=quote_data.get("authorName") or quote_data.get("authorProfileName"),
reply_to_is_own_message=self._quote_references_own_message(reply_to_id, reply_to_author),
)
logger.debug("Signal: message from %s in %s: %s",
redact_phone(sender), chat_id[:20], (text or "")[:50])
await self.handle_message(event)
def _remember_recipient_identifiers(self, number: Optional[str], service_id: Optional[str]) -> None:
"""Cache any number↔UUID mapping observed from Signal envelopes."""
if not number or not service_id or not _is_signal_service_id(service_id):
return
self._recipient_uuid_by_number[number] = service_id
self._recipient_number_by_uuid[service_id] = number
@staticmethod
def _extract_quote_author(quote_data: Any) -> Optional[str]:
"""Return the best available Signal sender identifier from quote metadata."""
if not isinstance(quote_data, dict):
return None
for key in _QUOTE_AUTHOR_KEYS:
value = quote_data.get(key)
if value:
return str(value)
return None
def _quote_references_own_message(self, reply_to_id: Optional[str], reply_to_author: Optional[str]) -> bool:
"""True when a Signal quote points at this adapter's outbound message."""
if reply_to_id and str(reply_to_id) in self._sent_message_timestamps:
return True
if not reply_to_author:
return False
author = str(reply_to_author).strip()
if self._account_normalized and author == self._account_normalized:
return True
cached_uuid = self._recipient_uuid_by_number.get(self._account_normalized)
if cached_uuid and author == cached_uuid:
return True
cached_number = self._recipient_number_by_uuid.get(author)
return bool(cached_number and cached_number == self._account_normalized)
def _remember_sent_message_timestamp(self, timestamp: Any) -> None:
"""Keep a bounded cache of outbound Signal timestamps for quote matching."""
if timestamp is None:
return
key = str(timestamp)
# Re-insert to mark most-recently-used so eviction drops genuinely old entries.
self._sent_message_timestamps.pop(key, None)
self._sent_message_timestamps[key] = None
while len(self._sent_message_timestamps) > self._max_sent_message_timestamps:
self._sent_message_timestamps.popitem(last=False)
def _extract_contact_uuid(self, contact: Any, phone_number: str) -> Optional[str]:
"""Best-effort extraction of a Signal service ID from listContacts output."""
if not isinstance(contact, dict):
return None
service_id = contact.get("uuid") or contact.get("serviceId")
if not service_id:
profile = contact.get("profile")
if isinstance(profile, dict):
service_id = profile.get("serviceId") or profile.get("uuid")
if service_id and _is_signal_service_id(service_id) and (
contact.get("number") == phone_number or contact.get("recipient") == phone_number
):
return service_id
return None
async def _resolve_recipient(self, chat_id: str) -> str:
"""Return the preferred Signal recipient identifier for a direct chat."""
if (
not chat_id
or chat_id.startswith("group:")
or _is_signal_service_id(chat_id)
or not _looks_like_e164_number(chat_id)
):
return chat_id
cached = self._recipient_uuid_by_number.get(chat_id)
if cached:
return cached
async with self._recipient_cache_lock:
cached = self._recipient_uuid_by_number.get(chat_id)
if cached:
return cached
contacts = await self._rpc("listContacts", {"account": self.account, "allRecipients": True})
if isinstance(contacts, list):
for contact in contacts:
number = contact.get("number") if isinstance(contact, dict) else None
service_id = self._extract_contact_uuid(contact, chat_id)
if number and service_id:
self._remember_recipient_identifiers(number, service_id)
return self._recipient_uuid_by_number.get(chat_id, chat_id)
async def _with_target(self, params: Dict[str, Any], chat_id: str, *, resolve: bool = True) -> Dict[str, Any]:
"""Add the groupId / recipient routing key for *chat_id* to *params* (in place)."""
if chat_id.startswith("group:"):
params["groupId"] = chat_id[6:]
elif resolve:
params["recipient"] = [await self._resolve_recipient(chat_id)]
else:
params["recipient"] = [chat_id]
return params
async def _fetch_attachment(self, attachment_id: str) -> tuple:
"""Fetch an attachment via JSON-RPC and cache it. Returns (path, ext)."""
result = await self._rpc("getAttachment", {"account": self.account, "id": attachment_id})
if not result:
return None, ""
# signal-cli returns {"data": "base64..."}
if isinstance(result, dict):
result = result.get("data")
if not result:
logger.warning("Signal: attachment response missing 'data' key")
return None, ""
raw_data = base64.b64decode(result)
ext = _guess_extension(raw_data)
# Android voice notes are raw ADTS AAC, which Whisper-style STT rejects; remux
# losslessly to .m4a. If ffmpeg is absent the raw file is cached as-is (there is
# no downstream sniff-and-remux fallback).
if ext == ".aac":
remuxed: Optional[Tuple[bytes, str]] = await asyncio.to_thread(_remux_aac_to_m4a, raw_data)
if remuxed is not None:
raw_data, ext = remuxed
if _is_image_ext(ext):
path = cache_image_from_bytes(raw_data, ext)
elif _is_audio_ext(ext):
path = cache_audio_from_bytes(raw_data, ext)
else:
path = cache_document_from_bytes(raw_data, ext)
return path, ext
async def _rpc(
self,
method: str,
params: dict,
rpc_id: str = None,
*,
log_failures: bool = True,
raise_on_rate_limit: bool = False,
timeout: float = 30.0,
) -> Any:
"""Send a JSON-RPC 2.0 request to signal-cli daemon.
``log_failures=False`` logs failures at DEBUG (typing path: silence repeated
NETWORK_FAILURE spam). ``raise_on_rate_limit=True`` raises ``SignalRateLimitError``
on a 429 / RateLimitException instead of swallowing it (multi-attachment sends).
"""
if not self.client:
logger.warning("Signal: RPC called but client not connected")
return None
if rpc_id is None:
rpc_id = f"{method}_{int(time.time() * 1000)}"
payload = {"jsonrpc": "2.0", "method": method, "params": params, "id": rpc_id}
fail_level = logging.WARNING if log_failures else logging.DEBUG
try:
resp = await self.client.post(f"{self.http_url}/api/v1/rpc", json=payload, timeout=timeout)
resp.raise_for_status()
data = resp.json()
if "error" in data:
err = data["error"]
if raise_on_rate_limit and _is_signal_rate_limit_error(err):
err_msg = str(err.get("message", "")) if isinstance(err, dict) else str(err)
raise SignalRateLimitError(err_msg, retry_after=_extract_retry_after_seconds(err))
logger.log(fail_level, "Signal RPC error (%s): %s", method, err)
return None
result = data.get("result")
if isinstance(result, dict) and raise_on_rate_limit:
results = result.get("results")
if isinstance(results, list):
for r in results:
if isinstance(r, dict) and r.get("type") == "RATE_LIMIT_FAILURE":
raise SignalRateLimitError(
"Rate limit exceeded for recipient", retry_after=r.get("retryAfterSeconds")
)
return result
except SignalRateLimitError:
raise
except Exception as e:
logger.log(fail_level, "Signal RPC %s failed: %s", method, e)
return None
@staticmethod
def _markdown_to_signal(text: str) -> tuple[str, list[str]]:
"""Backward-compatible wrapper around shared Signal formatting helper."""
return markdown_to_signal(text)
def format_message(self, content: str) -> str:
"""Plain-text fallback for the base-class send path; send() applies rich styles itself."""
return content
def _validate_send_result(self, result: Any) -> tuple[bool, Optional[str]]:
"""Validate signal-cli send response results. Returns (success, error_message)."""
if not result or not isinstance(result, dict):
return True, None
results = result.get("results")
if isinstance(results, list):
for r in results:
if not isinstance(r, dict):
continue
rtype = r.get("type")
if rtype and rtype != "SUCCESS":
return False, str(rtype)
if "success" in r and not r.get("success"):
return False, str(r.get("failure") or "Recipient delivery failed")
return True, None
@staticmethod
def _utf16_offsets(text: str) -> list[int]:
"""Return cumulative UTF-16 offsets for every Python character boundary."""
return [0, *itertools.accumulate(utf16_len(char) for char in text)]
@staticmethod
def _styles_for_chunk(text_styles: list[str], chunk_start: int, chunk_end: int) -> list[str]:
"""Translate full-message Signal styles into a chunk-local range list."""
adjusted: list[str] = []
for style_string in text_styles:
try:
start_s, length_s, style_type = style_string.split(":", 2)
style_start = int(start_s)
style_end = style_start + int(length_s)
except (TypeError, ValueError):
logger.debug("[Signal] Ignoring malformed textStyle range: %r", style_string)
continue
overlap_start = max(style_start, chunk_start)
overlap_end = min(style_end, chunk_end)
if overlap_start < overlap_end:
adjusted.append(f"{overlap_start - chunk_start}:{overlap_end - overlap_start}:{style_type}")
return adjusted
@classmethod
def _split_signal_formatted_message(
cls, plain_text: str, text_styles: list[str], max_length: int,
) -> list[tuple[str, list[str]]]:
"""Split converted Signal text into chunks, translating body ranges per chunk.
Splitting after conversion (not before) keeps styles that cross a chunk boundary
intact instead of leaking literal Markdown markers.
"""
if utf16_len(plain_text) <= max_length:
return [(plain_text, text_styles)]
indicator_reserve = 10 # Mirrors BasePlatformAdapter.truncate_message().
body_limit = max(1, max_length - indicator_reserve)
offsets = cls._utf16_offsets(plain_text)
chunks: list[tuple[str, list[str]]] = []
start_idx = 0
total_u16 = offsets[-1]
while offsets[start_idx] < total_u16:
end_budget = min(total_u16, offsets[start_idx] + body_limit)
end_idx = start_idx + 1
while end_idx < len(offsets) and offsets[end_idx] <= end_budget:
end_idx += 1
end_idx -= 1
if end_idx <= start_idx:
end_idx = start_idx + 1
chunk_styles = cls._styles_for_chunk(text_styles, offsets[start_idx], offsets[end_idx])
chunks.append((plain_text[start_idx:end_idx], chunk_styles))
start_idx = end_idx
if len(chunks) == 1:
return chunks
total = len(chunks)
return [
(f"{chunk_text} ({idx}/{total})", chunk_styles)
for idx, (chunk_text, chunk_styles) in enumerate(chunks, start=1)
]
async def _rpc_send(self, params: Dict[str, Any], fail_error: str) -> Tuple[Any, Optional[SendResult]]:
"""Run a ``send`` RPC, validate and track it; ``(result, None)`` or ``(None, failed SendResult)``."""
result = await self._rpc("send", params)
if result is None:
return None, SendResult(success=False, error=fail_error)
success, err_msg = self._validate_send_result(result)
if not success:
return None, SendResult(success=False, error=err_msg, raw_response=result)
self._track_sent_timestamp(result)
return result, None
async def send(
self,
chat_id: str,
content: str,
reply_to: Optional[str] = None,
metadata: Optional[Dict[str, Any]] = None,
) -> SendResult:
"""Send a text message with native Signal formatting."""
await self._stop_typing_indicator(chat_id)
if not content or not content.strip():
return SendResult(success=True, message_id=None)
base_params = await self._with_target({"account": self.account}, chat_id)
plain_message, message_styles = self._markdown_to_signal(content)
chunks = self._split_signal_formatted_message(plain_message, message_styles, self.MAX_MESSAGE_LENGTH)
last_result = None
for idx, (plain_text, text_styles) in enumerate(chunks, start=1):
params: Dict[str, Any] = dict(base_params, message=plain_text)
if text_styles:
if len(text_styles) == 1:
params["textStyle"] = text_styles[0]
else:
params["textStyles"] = text_styles
logger.info(
"[Signal] Sending response chunk %d/%d (%d chars) to %s",
idx, len(chunks), len(plain_text), chat_id,
)
last_result, err = await self._rpc_send(params, "RPC send failed")
if err:
return err
# Signal has no editable message identifier; message_id=None keeps the stream
# consumer on the non-edit fallback path.
return SendResult(success=True, message_id=None, raw_response=last_result)
def _track_sent_timestamp(self, rpc_result) -> None:
"""Record outbound message timestamp for echo-back filtering."""
ts = rpc_result.get("timestamp") if isinstance(rpc_result, dict) else None
if ts:
self._remember_sent_message_timestamp(ts)
now = time.monotonic()
# Re-insert to mark as most-recently-used.
self._recent_sent_timestamps.pop(ts, None)
self._recent_sent_timestamps[ts] = now
# Drop entries older than TTL first, then enforce the hard cap.
cutoff = now - self._recent_sent_ttl_seconds
while self._recent_sent_timestamps:
_, oldest_at = next(iter(self._recent_sent_timestamps.items()))
if oldest_at < cutoff:
self._recent_sent_timestamps.popitem(last=False)
else:
break
while len(self._recent_sent_timestamps) > self._max_recent_timestamps:
self._recent_sent_timestamps.popitem(last=False)
def _consume_sent_timestamp(self, ts) -> bool:
"""Pop a timestamp if it matches one we sent. Returns True on echo."""
if ts and ts in self._recent_sent_timestamps:
self._recent_sent_timestamps.pop(ts, None)
return True
return False
async def send_typing(self, chat_id: str, metadata=None) -> None:
"""Send a typing indicator (called every ~2s by base.py's ``_keep_typing``).
On NETWORK_FAILURE only the first consecutive failure logs at WARNING, and after
three failures the RPC is skipped for an exponential cooldown; success resets.
"""
now = time.monotonic()
if now < self._typing_skip_until.get(chat_id, 0.0):
return
params = await self._with_target({"account": self.account}, chat_id)
fails = self._typing_failures.get(chat_id, 0)
result = await self._rpc("sendTyping", params, rpc_id="typing", log_failures=(fails == 0))
if result is None:
fails += 1
self._typing_failures[chat_id] = fails
# After 3 consecutive failures back off exponentially (16s, 32s, 60s cap).
if fails >= 3:
self._typing_skip_until[chat_id] = now + min(60.0, 16.0 * (2 ** (fails - 3)))
else:
self._typing_failures.pop(chat_id, None)
self._typing_skip_until.pop(chat_id, None)
async def _resolve_image_path(self, image_url: str) -> Tuple[Optional[str], Optional[str], Any]:
"""Resolve an http(s):// or file:// image URL to ``(path, None, None)``, or
``(None, reason, detail)`` with reason download (exc) / missing / oversize (size)."""
if image_url.startswith("file://"):
file_path = unquote(image_url[7:])
else:
try:
file_path = await cache_image_from_url(image_url)
except Exception as e:
return None, "download", e
if not file_path or not Path(file_path).exists():
return None, "missing", None
file_size = Path(file_path).stat().st_size
if file_size > SIGNAL_MAX_ATTACHMENT_SIZE:
return None, "oversize", file_size
return file_path, None, None
async def send_multiple_images(
self,
chat_id: str,
images: List[Tuple[str, str]],
metadata: Optional[Dict[str, Any]] = None,
human_delay: float = 0.0,
) -> None:
"""Send a batch of images via chunked Signal RPC calls.
Alt texts are dropped (one shared body per send). Bad images are skipped with a
warning. ``human_delay`` is ignored: the rate-limit scheduler paces batches.
"""
if not images:
return
scheduler = get_scheduler()
logger.info(
"Signal send_multiple_images: received %d image(s) for %s — scheduler state: %s",
len(images), chat_id[:30], scheduler.state(),
)
await self._stop_typing_indicator(chat_id)
attachments: List[str] = []
skipped = {"download": 0, "missing": 0, "oversize": 0}
for image_url, _alt_text in images:
file_path, reason, detail = await self._resolve_image_path(image_url)
if reason == "download":
logger.warning("Signal: failed to download image %s: %s", image_url, detail)
elif reason == "missing":
logger.warning("Signal: image file not found for %s", image_url)
elif reason == "oversize":
logger.warning("Signal: image too large (%d bytes), skipping %s", detail, image_url)
if reason:
skipped[reason] += 1
continue
attachments.append(file_path)
if not attachments:
logger.error(
"Signal: no valid images in batch of %d (download=%d missing=%d oversize=%d)",
len(images), skipped["download"], skipped["missing"], skipped["oversize"],
)
return
logger.info(
"Signal send_multiple_images: %d/%d images valid, sending in chunks",
len(attachments), len(images),
)
base_params = await self._with_target({"account": self.account, "message": ""}, chat_id)
att_batches = [
attachments[i:i + SIGNAL_MAX_ATTACHMENTS_PER_MSG]
for i in range(0, len(attachments), SIGNAL_MAX_ATTACHMENTS_PER_MSG)
]
n_batches = len(att_batches)
for idx, att_batch in enumerate(att_batches, start=1):
n = len(att_batch)
estimated = scheduler.estimate_wait(n)
logger.debug("Signal batch %d/%d: %d attachments, estimated wait=%.1fs", idx, n_batches, n, estimated)
if estimated >= SIGNAL_BATCH_PACING_NOTICE_THRESHOLD:
await self._notify_batch_pacing(chat_id, idx, n_batches, estimated)
await self._send_attachment_batch(
scheduler, dict(base_params, attachments=att_batch), n, f"{idx}/{n_batches}",
)
async def _send_attachment_batch(self, scheduler, params: Dict[str, Any], n: int, label: str) -> None:
"""Send one attachment batch with rate-limit pacing and a single transient retry.
Tokens are deducted only on validated success (a None result means the server
never accepted the batch); 429s feed the scheduler before the retry.
"""
send_timeout = _signal_send_timeout(n)
for attempt in range(1, SIGNAL_RATE_LIMIT_MAX_ATTEMPTS + 1):
await scheduler.acquire(n)
try:
_rpc_t0 = time.monotonic()
result = await self._rpc("send", params, raise_on_rate_limit=True, timeout=send_timeout)
_rpc_duration = time.monotonic() - _rpc_t0
success, err_msg = self._validate_send_result(result) if result is not None else (False, None)
if success:
self._track_sent_timestamp(result)
await scheduler.report_rpc_duration(_rpc_duration, n)
logger.info(
"Signal batch %s: %d attachments sent in %.1fs (attempt %d/%d)",
label, n, _rpc_duration, attempt, SIGNAL_RATE_LIMIT_MAX_ATTEMPTS,
)
return
logger.error(
"Signal: RPC send failed for batch %s (%d attachments, attempt %d/%d, rpc_duration=%.1fs)%s",
label, n, attempt, SIGNAL_RATE_LIMIT_MAX_ATTEMPTS, _rpc_duration,
f": {err_msg}" if result is not None else "",
)
if attempt >= SIGNAL_RATE_LIMIT_MAX_ATTEMPTS:
return
backoff = 2.0 ** attempt
logger.info("Signal: retrying batch %s after %.1fs backoff", label, backoff)
await asyncio.sleep(backoff)
except SignalRateLimitError as e:
scheduler.feedback(e.retry_after, n)
retry_after = f"{e.retry_after:.0f}s" if e.retry_after else "unknown"
if attempt >= SIGNAL_RATE_LIMIT_MAX_ATTEMPTS:
logger.error(
"Signal: rate-limit retries exhausted on batch %s (%d attachments lost, server retry_after=%s)",
label, n, retry_after,
)
return
logger.warning(
"Signal: rate-limited on batch %s (attempt %d/%d, server retry_after=%s); "
"scheduler will pace the retry",
label, attempt, SIGNAL_RATE_LIMIT_MAX_ATTEMPTS, retry_after,
)
async def _notify_batch_pacing(self, chat_id: str, next_batch_idx: int, total_batches: int, wait_s: float) -> None:
"""Tell the user about an inter-batch pacing wait over the notice threshold (best-effort)."""
try:
await self.send(
chat_id,
f"(More images coming — pausing ~{_format_wait(wait_s)} "
f"for Signal rate limit, batch {next_batch_idx}/{total_batches}.)",
)
except Exception as e:
logger.warning("Signal: failed to send pacing notice: %s", e)
async def send_image(self, chat_id: str, image_url: str, caption: Optional[str] = None, **kwargs) -> SendResult:
"""Send an image. Supports http(s):// and file:// URLs."""
await self._stop_typing_indicator(chat_id)
file_path, reason, detail = await self._resolve_image_path(image_url)
if reason == "download":
logger.warning("Signal: failed to download image: %s", detail)
return SendResult(success=False, error=str(detail))
if reason == "missing":
return SendResult(success=False, error="Image file not found")
if reason == "oversize":
return SendResult(success=False, error=f"Image too large ({detail} bytes)")
return await self._send_file(chat_id, file_path, caption, "RPC send with attachment failed")
async def _send_file(self, chat_id: str, file_path: str, caption: Optional[str], fail_error: str) -> SendResult:
"""Send one local file as a Signal attachment via the ``send`` RPC."""
params = await self._with_target(
{"account": self.account, "message": caption or "", "attachments": [file_path]}, chat_id
)
_, err = await self._rpc_send(params, fail_error)
return err or SendResult(success=True)
async def _send_attachment(
self, chat_id: str, file_path: str, media_label: str, caption: Optional[str] = None,
) -> SendResult:
"""Send any local file as a Signal attachment (shared by send_document/image_file/voice/video)."""
await self._stop_typing_indicator(chat_id)
try:
file_size = Path(file_path).stat().st_size
except FileNotFoundError:
return SendResult(success=False, error=f"{media_label} file not found: {file_path}")
if file_size > SIGNAL_MAX_ATTACHMENT_SIZE:
return SendResult(success=False, error=f"{media_label} too large ({file_size} bytes)")
return await self._send_file(chat_id, file_path, caption, f"RPC send {media_label.lower()} failed")
async def send_document(
self, chat_id: str, file_path: str, caption: Optional[str] = None, filename: Optional[str] = None, **kwargs,
) -> SendResult:
"""Send a document/file attachment."""
return await self._send_attachment(chat_id, file_path, "File", caption)
async def send_image_file(
self, chat_id: str, image_path: str, caption: Optional[str] = None, reply_to: Optional[str] = None, **kwargs,
) -> SendResult:
"""Send a local image file as a native Signal attachment (gateway MEDIA: delivery path)."""
return await self._send_attachment(chat_id, image_path, "Image", caption)
async def send_voice(
self, chat_id: str, audio_path: str, caption: Optional[str] = None, reply_to: Optional[str] = None, **kwargs,
) -> SendResult:
"""Send an audio file as a Signal attachment (Signal has no distinct voice-message API)."""
return await self._send_attachment(chat_id, audio_path, "Audio", caption)
async def send_video(
self, chat_id: str, video_path: str, caption: Optional[str] = None, reply_to: Optional[str] = None, **kwargs,
) -> SendResult:
"""Send a video file as a Signal attachment."""
return await self._send_attachment(chat_id, video_path, "Video", caption)
async def _stop_typing_indicator(self, chat_id: str) -> None:
"""Stop a typing indicator loop for a chat."""
await self._cancel_task(self._typing_tasks.pop(chat_id, None))
# Explicit stop-typing RPC so the recipient drops the indicator now instead of
# after Signal's ~5s built-in timeout. Best-effort: any RPC or recipient-
# resolution failure must not prevent the backoff cleanup below.
try:
params = await self._with_target({"account": self.account}, chat_id)
params["stop"] = True
await self._rpc("sendTyping", params, rpc_id="typing-stop", log_failures=False)
except Exception:
pass
self._typing_failures.pop(chat_id, None)
self._typing_skip_until.pop(chat_id, None)
async def stop_typing(self, chat_id: str) -> None:
"""Public stop-typing hook called from the base adapter's _keep_typing finally block."""
await self._stop_typing_indicator(chat_id)
async def _send_reaction_rpc(self, chat_id: str, params: Dict[str, Any]) -> bool:
"""Route a ``sendReaction`` RPC to *chat_id* (no UUID upgrade — author IDs come from the envelope)."""
await self._with_target(params, chat_id, resolve=False)
return await self._rpc("sendReaction", params) is not None
async def send_reaction(self, chat_id: str, emoji: str, target_author: str, target_timestamp: int) -> bool:
"""React to the message (author number/UUID, Signal ms timestamp) via signal-cli RPC."""
ok = await self._send_reaction_rpc(chat_id, {
"account": self.account, "emoji": emoji, "targetAuthor": target_author,
"targetTimestamp": target_timestamp,
})
if not ok:
logger.debug("Signal: sendReaction failed (chat=%s, emoji=%s)", chat_id[:20], emoji)
return ok
async def remove_reaction(self, chat_id: str, target_author: str, target_timestamp: int) -> bool:
"""Remove a reaction by sending an empty-string emoji."""
return await self._send_reaction_rpc(chat_id, {
"account": self.account, "emoji": "", "targetAuthor": target_author,
"targetTimestamp": target_timestamp, "remove": True,
})
def _extract_reaction_target(self, event: MessageEvent) -> Optional[tuple]:
"""Extract (target_author, target_timestamp) from a MessageEvent, or None."""
raw = event.raw_message
if isinstance(raw, dict) and raw.get("sender") and raw.get("timestamp_ms"):
return (raw["sender"], raw["timestamp_ms"])
return None
def _reactions_enabled(self, event: "MessageEvent" = None) -> bool:
"""SIGNAL_REACTIONS env gate, then the DM allowlist: reactions fire before run.py's
auth gate, so an unauthorized contact's 👀 would otherwise reveal a listening bot."""
if os.getenv("SIGNAL_REACTIONS", "true").lower() in {"false", "0", "no"}:
return False
if event is not None:
sender = getattr(getattr(event, "source", None), "user_id", None)
if sender and "*" not in self.dm_allow_from and sender not in self.dm_allow_from:
return False
return True
async def on_processing_start(self, event: MessageEvent) -> None:
"""React with 👀 when processing begins."""
if not self._reactions_enabled(event):
return
target = self._extract_reaction_target(event)
if target:
await self.send_reaction(event.source.chat_id, "👀", *target)
async def on_processing_complete(self, event: MessageEvent, outcome: "ProcessingOutcome") -> None:
"""Swap 👀 for ✅/❌; on CANCELLED the 👀 stays to keep reflecting "in progress" (matches Telegram)."""
if not self._reactions_enabled(event):
return
if outcome == ProcessingOutcome.CANCELLED:
return
target = self._extract_reaction_target(event)
if not target:
return
chat_id = event.source.chat_id
await self.remove_reaction(chat_id, *target)
emoji = _OUTCOME_REACTION.get(outcome)
if emoji:
await self.send_reaction(chat_id, emoji, *target)
async def get_chat_info(self, chat_id: str) -> Dict[str, Any]:
"""Get information about a chat/contact."""
if chat_id.startswith("group:"):
return {"name": chat_id, "type": "group", "chat_id": chat_id}
result = await self._rpc("getContact", {"account": self.account, "contactAddress": chat_id})
name = chat_id
if result and isinstance(result, dict):
name = result.get("name") or result.get("profileName") or chat_id
return {"name": name, "type": "dm", "chat_id": chat_id}