import asyncio import json import re import time from urllib.parse import quote_plus, urlparse import aiohttp import websockets from asgiref.sync import sync_to_async from django.conf import settings from django.urls import reverse from signalbot import Command, Context, SignalBot from core.clients import ClientBase, signalapi, transport from core.messaging import ai, history, media_bridge, natural, replies, reply_sync, utils from core.models import ( Chat, Manipulation, Message, Person, PersonIdentifier, PlatformChatLink, QueuedMessage, ) from core.util import logs log = logs.get_logger("signalF") _signal_http_url = getattr(settings, "SIGNAL_HTTP_URL", "").strip() if _signal_http_url: parsed = urlparse( _signal_http_url if "://" in _signal_http_url else f"http://{_signal_http_url}" ) SIGNAL_HOST = parsed.hostname or "signal" SIGNAL_PORT = parsed.port or 8080 else: SIGNAL_HOST = "signal" SIGNAL_PORT = 8080 SIGNAL_URL = f"{SIGNAL_HOST}:{SIGNAL_PORT}" def _is_internal_compose_blob_url(value: str) -> bool: raw = str(value or "").strip() if not raw: return False if raw.startswith("/compose/media/blob/"): return True parsed = urlparse(raw if "://" in raw else f"https://dummy{raw}") return str(parsed.path or "").startswith("/compose/media/blob/") def _is_compose_blob_only_text(text_value: str) -> bool: lines = [ line.strip() for line in str(text_value or "").splitlines() if line.strip() ] if not lines: return False return all(_is_internal_compose_blob_url(line) for line in lines) def _get_nested(payload, path): current = payload for key in path: if not isinstance(current, dict): return None current = current.get(key) return current def _looks_like_signal_attachment(entry): return isinstance(entry, dict) and ( "id" in entry or "attachmentId" in entry or "contentType" in entry ) def _normalize_attachment(entry): attachment_id = entry.get("id") or entry.get("attachmentId") if attachment_id is None: return None return { "id": attachment_id, "content_type": entry.get("contentType", "application/octet-stream"), "filename": entry.get("filename") or str(attachment_id), "size": entry.get("size") or 0, "width": entry.get("width"), "height": entry.get("height"), } def _extract_attachments(raw_payload): envelope = raw_payload.get("envelope", {}) candidate_paths = [ ("dataMessage", "attachments"), ("syncMessage", "sentMessage", "attachments"), ("syncMessage", "editMessage", "dataMessage", "attachments"), ] results = [] seen = set() for path in candidate_paths: found = _get_nested(envelope, path) if not isinstance(found, list): continue for entry in found: normalized = _normalize_attachment(entry) if not normalized: continue key = str(normalized["id"]) if key in seen: continue seen.add(key) results.append(normalized) # Fallback: scan for attachment-shaped lists under envelope. if not results: stack = [envelope] while stack: node = stack.pop() if isinstance(node, dict): for value in node.values(): stack.append(value) elif isinstance(node, list): if node and all(_looks_like_signal_attachment(item) for item in node): for entry in node: normalized = _normalize_attachment(entry) if not normalized: continue key = str(normalized["id"]) if key in seen: continue seen.add(key) results.append(normalized) else: for value in node: stack.append(value) return results def _extract_receipt_timestamps(receipt_payload): raw_ts = receipt_payload.get("timestamp") if raw_ts is None: raw_ts = receipt_payload.get("timestamps") if isinstance(raw_ts, list): out = [] for item in raw_ts: try: out.append(int(item)) except Exception: continue return out if raw_ts is not None: try: return [int(raw_ts)] except Exception: return [] return [] def _extract_signal_reaction(envelope): paths = [ ("dataMessage", "reaction"), ("syncMessage", "sentMessage", "message", "reaction"), ("syncMessage", "sentMessage", "reaction"), ] node = None for path in paths: candidate = _get_nested(envelope, path) if isinstance(candidate, dict): node = candidate break if not isinstance(node, dict): return None emoji = str(node.get("emoji") or "").strip() target_ts = node.get("targetSentTimestamp") if target_ts is None: target_ts = node.get("targetTimestamp") try: target_ts = int(target_ts) except Exception: target_ts = 0 remove = bool(node.get("remove") or node.get("isRemove")) if not emoji and not remove: return None if target_ts <= 0: return None return { "emoji": emoji, "target_ts": target_ts, "remove": remove, "raw": dict(node), } def _extract_signal_text(raw_payload, default_text=""): text = str(default_text or "").strip() if text: return text payload = dict(raw_payload or {}) envelope = dict(payload.get("envelope") or {}) candidates = [ envelope.get("dataMessage"), _get_nested(envelope, ("syncMessage", "sentMessage", "message")), _get_nested(envelope, ("syncMessage", "sentMessage")), payload.get("dataMessage"), payload, ] for item in candidates: if isinstance(item, dict): for key in ("message", "text", "body", "caption"): value = str(item.get(key) or "").strip() if value: return value return "" def _typing_started(typing_payload): action = str(typing_payload.get("action") or "").strip().lower() if action in {"started", "start", "typing", "composing"}: return True explicit = typing_payload.get("isTyping") if isinstance(explicit, bool): return explicit return True def _identifier_candidates(*values): out = [] seen = set() for value in values: cleaned = str(value or "").strip() if not cleaned: continue candidates = [cleaned] digits = re.sub(r"[^0-9]", "", cleaned) # Add basic E.164 variants for phone-shaped values. if digits and cleaned.count("-") < 4: candidates.extend([digits, f"+{digits}"]) for candidate in candidates: if not candidate or candidate in seen: continue seen.add(candidate) out.append(candidate) return out def _digits_only(value): return re.sub(r"[^0-9]", "", str(value or "").strip()) class NewSignalBot(SignalBot): def __init__(self, ur, service, config): self.ur = ur self.service = service self.signal_rest = config["signal_service"] # keep your own copy self.phone_number = config["phone_number"] super().__init__(config) self.log = logs.get_logger("signalI") self.bot_uuid = None async def get_own_uuid(self) -> str | None: async with aiohttp.ClientSession() as session: # config may be "signal:8080" -- ensure http:// base = self.signal_rest if not base.startswith("http"): base = f"http://{base}" uri = f"{base}/v1/contacts/{self.phone_number}" try: resp = await session.get(uri) if resp.status != 200: self.log.error( f"contacts lookup failed: {resp.status} {await resp.text()}" ) return None contacts_data = await resp.json() if isinstance(contacts_data, list): for contact in contacts_data: if contact.get("number") == self.phone_number: return contact.get("uuid") return None except Exception as e: self.log.error(f"Failed to get UUID from contacts: {e}") return None async def initialize_bot(self): """Fetch bot's UUID and store it in self.bot_uuid.""" try: self.bot_uuid = await self.get_own_uuid() if self.bot_uuid: self.log.info(f"Own UUID: {self.bot_uuid}") else: self.log.warning("Unable to fetch bot UUID.") except Exception as e: self.log.error(f"Failed to initialize bot UUID: {e}") async def _async_post_init(self): """ Preserve SignalBot startup flow so protocol auto-detection runs. This flips the client to plain HTTP/WS when HTTPS/WSS is unavailable. """ await self._check_signal_service() await self.initialize_bot() await self._detect_groups() await self._resolve_commands() await self._produce_consume_messages() async def _upsert_groups(self) -> None: groups = getattr(self, "groups", None) or [] if not groups: self.log.debug("[Signal] _upsert_groups: no groups to persist") return identifiers = await sync_to_async(list)( PersonIdentifier.objects.filter(service="signal").select_related("user") ) seen_user_ids: set = set() users = [] for pi in identifiers: if pi.user_id not in seen_user_ids: seen_user_ids.add(pi.user_id) users.append(pi.user) if not users: self.log.debug("[Signal] _upsert_groups: no PersonIdentifiers found — skipping") return for user in users: for group in groups: group_id = group.get("id") or "" name = group.get("name") or group_id if not group_id: continue await sync_to_async(PlatformChatLink.objects.update_or_create)( user=user, service="signal", chat_identifier=group_id, defaults={ "person": None, "person_identifier": None, "is_group": True, "chat_name": name, }, ) self.log.info("[Signal] upserted %d groups for %d users", len(groups), len(users)) async def _detect_groups(self): await super()._detect_groups() await self._upsert_groups() def start(self): """Start bot without blocking the caller's event loop.""" task = self._event_loop.create_task( self._rerun_on_exception(self._async_post_init) ) self._store_reference_to_task(task, self._running_tasks) self.scheduler.start() class HandleMessage(Command): def __init__(self, ur, service, *args, **kwargs): self.ur = ur self.service = service return super().__init__(*args, **kwargs) async def handle(self, c: Context): msg = { "source": c.message.source, "source_number": c.message.source_number, "source_uuid": c.message.source_uuid, "timestamp": c.message.timestamp, "type": c.message.type.value, "text": c.message.text, "group": c.message.group, "reaction": c.message.reaction, "mentions": c.message.mentions, "raw_message": c.message.raw_message, } raw = json.loads(c.message.raw_message) sent_message = ( raw.get("envelope", {}).get("syncMessage", {}).get("sentMessage", {}) or {} ) dest = sent_message.get("destinationUuid") account = raw.get("account", "") source_name = raw.get("envelope", {}).get("sourceName", "") source_number = c.message.source_number source_uuid = c.message.source_uuid text = c.message.text text = _extract_signal_text(raw, text) ts = c.message.timestamp source_value = c.message.source envelope = raw.get("envelope", {}) envelope_source_uuid = envelope.get("sourceUuid") envelope_source_number = envelope.get("sourceNumber") effective_source_uuid = str(envelope_source_uuid or source_uuid or "").strip() effective_source_number = str( envelope_source_number or source_number or "" ).strip() signal_source_message_id = str( envelope.get("serverGuid") or envelope.get("guid") or envelope.get("timestamp") or c.message.timestamp or "" ).strip() destination_number = sent_message.get("destination") bot_uuid = str(getattr(c.bot, "bot_uuid", "") or "").strip() bot_phone = str(getattr(c.bot, "phone_number", "") or "").strip() source_uuid_norm = effective_source_uuid source_number_norm = effective_source_number dest_norm = str(dest or "").strip() destination_number_norm = str(destination_number or "").strip() bot_phone_digits = re.sub(r"[^0-9]", "", bot_phone) source_phone_digits = re.sub(r"[^0-9]", "", source_number_norm) dest_phone_digits = re.sub(r"[^0-9]", "", destination_number_norm or dest_norm) is_sync_outbound = bool(dest_norm or destination_number_norm) # Message originating from us same_recipient = bool( source_uuid_norm and dest_norm and source_uuid_norm == dest_norm ) is_from_bot = bool(bot_uuid and source_uuid_norm and source_uuid_norm == bot_uuid) if (not is_from_bot) and bot_phone_digits and source_phone_digits: is_from_bot = source_phone_digits == bot_phone_digits # Inbound deliveries usually do not have destination fields populated. # When destination is missing, treat event as inbound even if source # metadata drifts to our own identifiers. if not is_sync_outbound: is_from_bot = False # For non-sync incoming events destination is usually absent and points to us. is_to_bot = bool(bot_uuid and dest_norm and dest_norm == bot_uuid) if (not is_to_bot) and bot_phone_digits and dest_phone_digits: is_to_bot = dest_phone_digits == bot_phone_digits if (not is_to_bot) and (not dest_norm) and (not destination_number_norm): is_to_bot = True reply_to_self = same_recipient and is_from_bot # Reply reply_to_others = is_to_bot and not same_recipient # Reply is_outgoing_message = is_from_bot and not is_to_bot # Do not reply envelope_source = envelope.get("source") primary_identifier = dest if is_from_bot else effective_source_uuid if (dest or destination_number) and is_from_bot: # Sync "sentMessage" events are outbound; route by destination only. # This prevents copying one outbound message into multiple people # when source fields include the bot's own identifier. identifier_candidates = _identifier_candidates(dest, destination_number) elif is_from_bot: identifier_candidates = _identifier_candidates(primary_identifier) else: bot_identifiers = { str(c.bot.bot_uuid or "").strip(), str(getattr(c.bot, "phone_number", "") or "").strip(), } incoming_candidates = _identifier_candidates( primary_identifier, effective_source_uuid, effective_source_number, source_value, envelope_source_uuid, envelope_source_number, envelope_source, ) identifier_candidates = [ value for value in incoming_candidates if value and value not in bot_identifiers ] if not identifier_candidates: log.warning("No Signal identifier available for message routing.") return # Resolve person identifiers once for this event. identifiers = await sync_to_async(list)( PersonIdentifier.objects.filter( identifier__in=identifier_candidates, service=self.service, ) ) if not identifiers: companion_candidates = [] for value in identifier_candidates: if not value: continue companions = await sync_to_async(list)( Chat.objects.filter(source_uuid=value).values_list( "source_number", flat=True ) ) companions += await sync_to_async(list)( Chat.objects.filter(source_number=value).values_list( "source_uuid", flat=True ) ) companion_candidates.extend(companions) companion_candidates = _identifier_candidates(*companion_candidates) if companion_candidates: identifiers = await sync_to_async(list)( PersonIdentifier.objects.filter( identifier__in=companion_candidates, service=self.service, ) ) if not identifiers: # Final fallback: compare normalized phone digits to handle format drift # between Signal payload values and stored identifiers. candidate_digits = {_digits_only(value) for value in identifier_candidates} candidate_digits = {value for value in candidate_digits if value} if candidate_digits: signal_rows = await sync_to_async(list)( PersonIdentifier.objects.filter(service=self.service).select_related( "user" ) ) matched = [] for row in signal_rows: stored_digits = _digits_only(row.identifier) if stored_digits and stored_digits in candidate_digits: matched.append(row) identifiers = matched if not identifiers and (not is_from_bot) and (not bool(c.message.group)): # Single-user fallback: don't drop new private inbound contacts just # because they are not pre-linked yet. Create a placeholder person + # identifier so the chat appears and can be re-linked later. owner_rows = await sync_to_async(list)( PersonIdentifier.objects.filter(service=self.service) .select_related("user") .order_by("user_id", "id") ) owner_users = [] seen_user_ids = set() for row in owner_rows: if row.user_id in seen_user_ids: continue seen_user_ids.add(row.user_id) owner_users.append(row.user) if len(owner_users) == 1: owner = owner_users[0] fallback_identifier = ( effective_source_number or effective_source_uuid or (identifier_candidates[0] if identifier_candidates else "") ) fallback_identifier = str(fallback_identifier or "").strip() if fallback_identifier: person, _ = await sync_to_async(Person.objects.get_or_create)( user=owner, name=f"Signal {fallback_identifier}", ) pi, _ = await sync_to_async(PersonIdentifier.objects.get_or_create)( user=owner, service=self.service, identifier=fallback_identifier, defaults={"person": person}, ) if pi.person_id != person.id: pi.person = person await sync_to_async(pi.save)(update_fields=["person"]) identifiers = [pi] log.info( "Signal inbound auto-linked new private contact identifier=%s user_id=%s", fallback_identifier, int(owner.id), ) if not identifiers: log.warning( "Signal inbound unmatched: candidates=%s source_uuid=%s source_number=%s effective_source_uuid=%s effective_source_number=%s dest=%s destination_number=%s envelope_source_uuid=%s envelope_source_number=%s", identifier_candidates, str(source_uuid or ""), str(source_number or ""), str(effective_source_uuid or ""), str(effective_source_number or ""), str(dest or ""), str(destination_number or ""), str(envelope_source_uuid or ""), str(envelope_source_number or ""), ) typing_payload = envelope.get("typingMessage") if isinstance(typing_payload, dict): for identifier in identifiers: if _typing_started(typing_payload): await self.ur.started_typing( self.service, identifier=identifier, payload=typing_payload, ) else: await self.ur.stopped_typing( self.service, identifier=identifier, payload=typing_payload, ) return receipt_payload = envelope.get("receiptMessage") if isinstance(receipt_payload, dict): read_timestamps = _extract_receipt_timestamps(receipt_payload) read_ts = ( envelope.get("timestamp") or envelope.get("serverReceivedTimestamp") or c.message.timestamp ) for identifier in identifiers: await self.ur.message_read( self.service, identifier=identifier, message_timestamps=read_timestamps, read_ts=read_ts, payload=receipt_payload, read_by=(effective_source_uuid or effective_source_number or ""), ) return reaction_payload = _extract_signal_reaction(envelope) if isinstance(reaction_payload, dict): log.debug( "reaction-bridge signal-inbound target_ts=%s emoji=%s remove=%s identifiers=%s", int(reaction_payload.get("target_ts") or 0), str(reaction_payload.get("emoji") or "") or "-", bool(reaction_payload.get("remove")), len(identifiers), ) for identifier in identifiers: try: await history.apply_reaction( identifier.user, identifier, target_message_id="", target_ts=int(reaction_payload.get("target_ts") or 0), emoji=str(reaction_payload.get("emoji") or ""), source_service="signal", actor=( effective_source_uuid or effective_source_number or "" ), target_author=str( (reaction_payload.get("raw") or {}).get("targetAuthorUuid") or (reaction_payload.get("raw") or {}).get("targetAuthor") or (reaction_payload.get("raw") or {}).get("targetAuthorNumber") or "" ), remove=bool(reaction_payload.get("remove")), payload=reaction_payload.get("raw") or {}, ) except Exception as exc: log.warning("Signal reaction history apply failed: %s", exc) try: await self.ur.xmpp.client.apply_external_reaction( identifier.user, identifier, source_service="signal", emoji=str(reaction_payload.get("emoji") or ""), remove=bool(reaction_payload.get("remove")), upstream_message_id="", upstream_ts=int(reaction_payload.get("target_ts") or 0), actor=( effective_source_uuid or effective_source_number or "" ), payload=reaction_payload.get("raw") or {}, ) except Exception as exc: log.warning("Signal reaction relay to XMPP failed: %s", exc) return # Handle attachments across multiple Signal payload variants. attachment_list = _extract_attachments(raw) xmpp_attachments = [] compose_media_urls = [] # Asynchronously fetch all attachments log.info(f"ATTACHMENT LIST {attachment_list}") if attachment_list: tasks = [ signalapi.fetch_signal_attachment(att["id"]) for att in attachment_list ] fetched_attachments = await asyncio.gather(*tasks) else: envelope = raw.get("envelope", {}) log.info(f"No attachments found. Envelope keys: {list(envelope.keys())}") fetched_attachments = [] for fetched, att in zip(fetched_attachments, attachment_list): if not fetched: log.warning(f"Failed to fetch attachment {att['id']} from Signal.") continue # Attach fetched file to XMPP xmpp_attachments.append( { "content": fetched["content"], "content_type": fetched["content_type"], "filename": fetched["filename"], "size": fetched["size"], } ) blob_key = media_bridge.put_blob( service="signal", content=fetched["content"], filename=fetched["filename"], content_type=fetched["content_type"], ) if blob_key: compose_media_urls.append( f"/compose/media/blob/?key={quote_plus(str(blob_key))}" ) # Keep relay payload text clean for XMPP. Blob URLs are web/history fallback # only and should not be injected into XMPP body text. relay_text = text if attachment_list and _is_compose_blob_only_text(relay_text): relay_text = "" # Forward incoming Signal messages to XMPP and apply mutate rules. identifier_text_overrides = {} for identifier in identifiers: user = identifier.user session_key = (identifier.user.id, identifier.person.id) mutate_manips = await sync_to_async(list)( Manipulation.objects.filter( group__people=identifier.person, user=identifier.user, mode="mutate", filter_enabled=True, enabled=True, ) ) if mutate_manips: uploaded_urls = [] for manip in mutate_manips: prompt = replies.generate_mutate_reply_prompt( relay_text, None, manip, None, ) log.info("Running Signal mutate prompt") result = await ai.run_prompt( prompt, manip.ai, operation="signal_mutate", ) log.info( f"Sending {len(xmpp_attachments)} attachments from Signal to XMPP." ) uploaded_urls = await self.ur.xmpp.client.send_from_external( user, identifier, result, is_outgoing_message, attachments=xmpp_attachments, source_ref={ "upstream_message_id": "", "upstream_author": str( effective_source_uuid or effective_source_number or "" ), "upstream_ts": int(ts or 0), }, ) resolved_text = relay_text if (not resolved_text) and uploaded_urls: resolved_text = "\n".join(uploaded_urls) elif (not resolved_text) and compose_media_urls: resolved_text = "\n".join(compose_media_urls) identifier_text_overrides[session_key] = resolved_text else: log.info( f"Sending {len(xmpp_attachments)} attachments from Signal to XMPP." ) uploaded_urls = await self.ur.xmpp.client.send_from_external( user, identifier, relay_text, is_outgoing_message, attachments=xmpp_attachments, source_ref={ "upstream_message_id": "", "upstream_author": str( effective_source_uuid or effective_source_number or "" ), "upstream_ts": int(ts or 0), }, ) resolved_text = relay_text if (not resolved_text) and uploaded_urls: resolved_text = "\n".join(uploaded_urls) elif (not resolved_text) and compose_media_urls: resolved_text = "\n".join(compose_media_urls) identifier_text_overrides[session_key] = resolved_text # Persist message history for every resolved identifier, even when no # manipulations are active, so manual chat windows stay complete. session_cache = {} stored_messages = set() for identifier in identifiers: session_key = (identifier.user.id, identifier.person.id) if session_key in session_cache: chat_session = session_cache[session_key] else: chat_session = await history.get_chat_session( identifier.user, identifier ) session_cache[session_key] = chat_session reply_ref = reply_sync.extract_reply_ref(self.service, raw) reply_target = await reply_sync.resolve_reply_target( identifier.user, chat_session, reply_ref, ) sender_key = ( effective_source_uuid or effective_source_number or identifier_candidates[0] ) message_key = (chat_session.id, ts, sender_key) message_text = identifier_text_overrides.get(session_key, relay_text) if message_key not in stored_messages: origin_tag = reply_sync.extract_origin_tag(raw) local_message = await history.store_message( session=chat_session, sender=sender_key, text=message_text, ts=ts, outgoing=is_from_bot, source_service=self.service, source_message_id=signal_source_message_id, source_chat_id=str( destination_number_norm or dest_norm or sender_key or "" ), reply_to=reply_target, reply_source_service=str( reply_ref.get("reply_source_service") or "" ), reply_source_message_id=str( reply_ref.get("reply_source_message_id") or "" ), message_meta=reply_sync.apply_sync_origin({}, origin_tag), ) stored_messages.add(message_key) # Notify unified router to ensure service context is preserved await self.ur.message_received( self.service, identifier=identifier, text=message_text, ts=ts, payload=msg, local_message=local_message, ) # TODO: Permission checks manips = await sync_to_async(list)(Manipulation.objects.filter(enabled=True)) for manip in manips: person_identifier = await sync_to_async( lambda: PersonIdentifier.objects.filter( identifier__in=identifier_candidates, user=manip.user, service="signal", person__in=manip.group.people.all(), ).first() )() if person_identifier is None: log.warning( f"{manip.name}: Message from unknown identifier(s) " f"{', '.join(identifier_candidates)}." ) continue # Find/create ChatSession once per user/person. session_key = (manip.user.id, person_identifier.person.id) if session_key in session_cache: chat_session = session_cache[session_key] else: chat_session = await history.get_chat_session( manip.user, person_identifier ) session_cache[session_key] = chat_session # Get the total history chat_history = await history.get_chat_history(chat_session) if replies.should_reply( reply_to_self, reply_to_others, is_outgoing_message, ): if manip.mode in ["silent", "mutate"]: pass elif manip.mode in ["active", "notify", "instant"]: await utils.update_last_interaction(chat_session) prompt = replies.generate_reply_prompt( msg, person_identifier.person, manip, chat_history ) log.info("Running context prompt") result = await ai.run_prompt( prompt, manip.ai, operation="signal_reply", ) if manip.mode == "active": await history.store_own_message( session=chat_session, text=result, ts=ts + 1, ) await self.ur.xmpp.client.send_from_external( manip.user, person_identifier, result, is_outgoing_message=True, ) await natural.natural_send_message( result, c.send, c.start_typing, c.stop_typing, ) elif manip.mode == "notify": title = f"[GIA] Suggested message to {person_identifier.person.name}" manip.user.sendmsg(result, title=title) elif manip.mode == "instant": existing_queue = QueuedMessage.objects.filter( user=chat_session.user, session=chat_session, manipulation=manip, custom_author="BOT", ) await history.delete_queryset(existing_queue) qm = await history.store_own_message( session=chat_session, text=result, ts=ts + 1, manip=manip, queue=True, ) accept = reverse( "message_accept_api", kwargs={"message_id": qm.id} ) reject = reverse( "message_reject_api", kwargs={"message_id": qm.id} ) url = settings.URL content = ( f"{result}\n\n" f"Accept: {url}{accept}\n" f"Reject: {url}{reject}" ) title = f"[GIA] Suggested message to {person_identifier.person.name}" manip.user.sendmsg(content, title=title) else: log.error(f"Mode {manip.mode} is not implemented") chat_lookup = {"account": account} if effective_source_uuid: chat_lookup["source_uuid"] = effective_source_uuid elif effective_source_number: chat_lookup["source_number"] = effective_source_number else: return await sync_to_async(Chat.objects.update_or_create)( **chat_lookup, defaults={ "source_uuid": effective_source_uuid, "source_number": effective_source_number, "source_name": source_name, "account": account, }, ) class SignalClient(ClientBase): def __init__(self, ur, *args, **kwargs): super().__init__(ur, *args, **kwargs) self._stopping = False signal_number = str(getattr(settings, "SIGNAL_NUMBER", "")).strip() self.client = NewSignalBot( ur, self.service, { "signal_service": SIGNAL_URL, "phone_number": signal_number, }, ) self.client.register(HandleMessage(self.ur, self.service)) self._command_task = None self._raw_receive_task = None async def _drain_runtime_commands(self): """Process queued runtime commands (e.g., web UI sends via composite router).""" from core.clients import transport # Process a small burst each loop to keep sends responsive. for _ in range(5): command = transport.pop_runtime_command(self.service) if not command: return await self._execute_runtime_command(command) async def _execute_runtime_command(self, command): """Execute a single runtime command like send_message_raw.""" from core.clients import transport command_id = str((command or {}).get("id") or "").strip() action = str((command or {}).get("action") or "").strip() payload = dict((command or {}).get("payload") or {}) if not command_id: return if action == "send_message_raw": recipient = str(payload.get("recipient") or "").strip() text = payload.get("text") attachments = payload.get("attachments") or [] metadata = dict(payload.get("metadata") or {}) try: result = await signalapi.send_message_raw( recipient_uuid=recipient, text=text, attachments=attachments, metadata=metadata, detailed=True, ) if isinstance(result, dict) and (not bool(result.get("ok"))): status_value = int(result.get("status") or 0) error_text = str(result.get("error") or "").strip() recipient_value = str(result.get("recipient") or recipient).strip() raise RuntimeError( "signal_send_failed" f" status={status_value or 'unknown'}" f" recipient={recipient_value or 'unknown'}" f" error={error_text or 'unknown'}" ) if result is False or result is None: raise RuntimeError("signal_send_failed") legacy_message_id = str(metadata.get("legacy_message_id") or "").strip() if legacy_message_id and isinstance(result, int): message_row = await sync_to_async( lambda: Message.objects.filter(id=legacy_message_id).first() )() if message_row is not None: update_fields = [] if int(message_row.delivered_ts or 0) <= 0: message_row.delivered_ts = int(result) update_fields.append("delivered_ts") if str(message_row.source_message_id or "").strip() != str(result): message_row.source_message_id = str(result) update_fields.append("source_message_id") if update_fields: await sync_to_async(message_row.save)( update_fields=update_fields ) transport.set_runtime_command_result( self.service, command_id, { "ok": True, "timestamp": int(result) if isinstance(result, int) else int(time.time() * 1000), }, ) except Exception as exc: self.log.error(f"send_message_raw failed: {exc}", exc_info=True) transport.set_runtime_command_result( self.service, command_id, { "ok": False, "error": str(exc), }, ) return if action == "notify_xmpp_sent": person_identifier_id = str( payload.get("person_identifier_id") or "" ).strip() text = str(payload.get("text") or "") if not person_identifier_id: transport.set_runtime_command_result( self.service, command_id, {"ok": False, "error": "missing_person_identifier_id"}, ) return try: identifier = await sync_to_async( lambda: PersonIdentifier.objects.filter(id=person_identifier_id) .select_related("user", "person") .first() )() if identifier is None: transport.set_runtime_command_result( self.service, command_id, {"ok": False, "error": "person_identifier_not_found"}, ) return await self.ur.xmpp.client.send_from_external( identifier.user, identifier, text, True, attachments=[], ) transport.set_runtime_command_result( self.service, command_id, {"ok": True, "timestamp": int(time.time() * 1000)}, ) except Exception as exc: transport.set_runtime_command_result( self.service, command_id, {"ok": False, "error": str(exc)}, ) return transport.set_runtime_command_result( self.service, command_id, {"ok": False, "error": f"unsupported_action:{action or '-'}"}, ) async def _command_loop(self): """Background task to periodically drain queued commands.""" while not self._stopping: try: await self._drain_runtime_commands() except Exception as exc: self.log.warning(f"Command loop error: {exc}") await asyncio.sleep(1) async def _resolve_signal_identifiers(self, source_uuid: str, source_number: str): candidates = _identifier_candidates(source_uuid, source_number) if not candidates: return [] identifiers = await sync_to_async(list)( PersonIdentifier.objects.filter( identifier__in=candidates, service=self.service, ) ) if identifiers: return identifiers candidate_digits = {_digits_only(value) for value in candidates} candidate_digits = {value for value in candidate_digits if value} if not candidate_digits: return [] rows = await sync_to_async(list)( PersonIdentifier.objects.filter(service=self.service).select_related("user") ) return [ row for row in rows if _digits_only(getattr(row, "identifier", "")) in candidate_digits ] async def _auto_link_single_user_signal_identifier(self, source_uuid: str, source_number: str): owner_rows = await sync_to_async(list)( PersonIdentifier.objects.filter(service=self.service) .select_related("user") .order_by("user_id", "id") ) users = [] seen = set() for row in owner_rows: if row.user_id in seen: continue seen.add(row.user_id) users.append(row.user) if len(users) != 1: return [] owner = users[0] fallback_identifier = str(source_number or source_uuid or "").strip() if not fallback_identifier: return [] person, _ = await sync_to_async(Person.objects.get_or_create)( user=owner, name=f"Signal {fallback_identifier}", ) pi, _ = await sync_to_async(PersonIdentifier.objects.get_or_create)( user=owner, service=self.service, identifier=fallback_identifier, defaults={"person": person}, ) if pi.person_id != person.id: pi.person = person await sync_to_async(pi.save)(update_fields=["person"]) self.log.info( "signal raw-receive auto-linked identifier=%s user_id=%s", fallback_identifier, int(owner.id), ) return [pi] async def _process_raw_inbound_event(self, raw_message: str): try: payload = json.loads(raw_message or "{}") except Exception: return exception_payload = payload.get("exception") if isinstance(payload, dict) else None if isinstance(exception_payload, dict): err_type = str(exception_payload.get("type") or "").strip() err_msg = str(exception_payload.get("message") or "").strip() envelope = payload.get("envelope") or {} envelope_source_uuid = "" envelope_source_number = "" envelope_ts = 0 envelope_keys = [] if isinstance(envelope, dict): envelope_source_uuid = str(envelope.get("sourceUuid") or "").strip() envelope_source_number = str(envelope.get("sourceNumber") or "").strip() try: envelope_ts = int( envelope.get("timestamp") or envelope.get("serverReceivedTimestamp") or 0 ) except Exception: envelope_ts = 0 envelope_keys = sorted(list(envelope.keys()))[:20] payload_excerpt = json.dumps(payload, ensure_ascii=True)[:1200] transport.update_runtime_state( self.service, last_inbound_exception_type=err_type, last_inbound_exception_message=err_msg, last_inbound_exception_ts=int( (envelope.get("timestamp") if isinstance(envelope, dict) else 0) or int(time.time() * 1000) ), last_inbound_exception_account=str(payload.get("account") or "").strip(), last_inbound_exception_source_uuid=envelope_source_uuid, last_inbound_exception_source_number=envelope_source_number, last_inbound_exception_envelope_ts=envelope_ts, last_inbound_exception_envelope_keys=envelope_keys, last_inbound_exception_payload_excerpt=payload_excerpt, ) self.log.warning( "signal raw-receive exception type=%s message=%s source_uuid=%s source_number=%s envelope_ts=%s", err_type or "-", err_msg or "-", envelope_source_uuid or "-", envelope_source_number or "-", envelope_ts or 0, ) return envelope = payload.get("envelope") or {} if not isinstance(envelope, dict): return sync_sent_message = _get_nested(envelope, ("syncMessage", "sentMessage")) or {} if isinstance(sync_sent_message, dict) and sync_sent_message: raw_text = sync_sent_message.get("message") if isinstance(raw_text, dict): text = _extract_signal_text( {"envelope": {"syncMessage": {"sentMessage": {"message": raw_text}}}}, str( raw_text.get("message") or raw_text.get("text") or raw_text.get("body") or "" ).strip(), ) else: text = _extract_signal_text(payload, str(raw_text or "").strip()) destination_uuid = str( sync_sent_message.get("destinationUuid") or sync_sent_message.get("destination") or "" ).strip() destination_number = str( sync_sent_message.get("destinationNumber") or sync_sent_message.get("destinationE164") or sync_sent_message.get("destination") or "" ).strip() identifiers = await self._resolve_signal_identifiers( destination_uuid, destination_number, ) if not identifiers: identifiers = await self._auto_link_single_user_signal_identifier( destination_uuid, destination_number, ) reaction_payload = _extract_signal_reaction(envelope) if identifiers and isinstance(reaction_payload, dict): source_uuid = str( envelope.get("sourceUuid") or envelope.get("source") or "" ).strip() source_number = str(envelope.get("sourceNumber") or "").strip() for identifier in identifiers: try: await history.apply_reaction( identifier.user, identifier, target_message_id="", target_ts=int(reaction_payload.get("target_ts") or 0), emoji=str(reaction_payload.get("emoji") or ""), source_service="signal", actor=(source_uuid or source_number or ""), target_author=str( (reaction_payload.get("raw") or {}).get("targetAuthorUuid") or (reaction_payload.get("raw") or {}).get("targetAuthor") or (reaction_payload.get("raw") or {}).get("targetAuthorNumber") or "" ), remove=bool(reaction_payload.get("remove")), payload=reaction_payload.get("raw") or {}, ) except Exception as exc: self.log.warning( "signal raw sync reaction history apply failed: %s", exc ) try: await self.ur.xmpp.client.apply_external_reaction( identifier.user, identifier, source_service="signal", emoji=str(reaction_payload.get("emoji") or ""), remove=bool(reaction_payload.get("remove")), upstream_message_id="", upstream_ts=int(reaction_payload.get("target_ts") or 0), actor=(source_uuid or source_number or ""), payload=reaction_payload.get("raw") or {}, ) except Exception as exc: self.log.warning( "signal raw sync reaction relay to XMPP failed: %s", exc ) if identifiers and text: ts_raw = ( sync_sent_message.get("timestamp") or envelope.get("timestamp") or envelope.get("serverReceivedTimestamp") or int(time.time() * 1000) ) try: ts = int(ts_raw) except Exception: ts = int(time.time() * 1000) source_message_id = str( envelope.get("serverGuid") or envelope.get("guid") or envelope.get("timestamp") or ts ).strip() sender_key = ( str(getattr(self.client, "bot_uuid", "") or "").strip() or str(getattr(self.client, "phone_number", "") or "").strip() or str(payload.get("account") or "").strip() or "self" ) source_chat_id = destination_number or destination_uuid or sender_key reply_ref = reply_sync.extract_reply_ref(self.service, payload) for identifier in identifiers: session = await history.get_chat_session(identifier.user, identifier) reply_target = await reply_sync.resolve_reply_target( identifier.user, session, reply_ref, ) exists = await sync_to_async( lambda: Message.objects.filter( user=identifier.user, session=session, source_service=self.service, source_message_id=source_message_id, ).exists() )() if exists: continue await history.store_message( session=session, sender=sender_key, text=text, ts=ts, outgoing=True, source_service=self.service, source_message_id=source_message_id, source_chat_id=source_chat_id, reply_to=reply_target, reply_source_service=str( reply_ref.get("reply_source_service") or "" ), reply_source_message_id=str( reply_ref.get("reply_source_message_id") or "" ), message_meta={}, ) transport.update_runtime_state( self.service, last_inbound_ok_ts=int(time.time() * 1000), last_inbound_exception_type="", last_inbound_exception_message="", ) return if envelope.get("typingMessage") or envelope.get("receiptMessage"): return data_message = envelope.get("dataMessage") or {} if not isinstance(data_message, dict): return source_uuid = str(envelope.get("sourceUuid") or envelope.get("source") or "").strip() source_number = str(envelope.get("sourceNumber") or "").strip() bot_uuid = str(getattr(self.client, "bot_uuid", "") or "").strip() bot_phone = str(getattr(self.client, "phone_number", "") or "").strip() if source_uuid and bot_uuid and source_uuid == bot_uuid: return if source_number and bot_phone and _digits_only(source_number) == _digits_only(bot_phone): return identifiers = await self._resolve_signal_identifiers(source_uuid, source_number) reaction_payload = _extract_signal_reaction(envelope) if (not identifiers) and isinstance(reaction_payload, dict): # Sync reactions from our own linked device can arrive with source=our # account and destination=. Resolve by destination as fallback. destination_uuid = str( envelope.get("destinationServiceId") or envelope.get("destinationUuid") or "" ).strip() destination_number = str(envelope.get("destinationNumber") or "").strip() if destination_uuid or destination_number: identifiers = await self._resolve_signal_identifiers( destination_uuid, destination_number, ) if not identifiers: identifiers = await self._auto_link_single_user_signal_identifier( source_uuid, source_number ) if not identifiers: self.log.warning( "signal raw-receive unmatched source_uuid=%s source_number=%s text=%s", source_uuid, source_number, str(data_message.get("message") or "")[:160], ) return if isinstance(reaction_payload, dict): for identifier in identifiers: try: await history.apply_reaction( identifier.user, identifier, target_message_id="", target_ts=int(reaction_payload.get("target_ts") or 0), emoji=str(reaction_payload.get("emoji") or ""), source_service="signal", actor=(source_uuid or source_number or ""), target_author=str( (reaction_payload.get("raw") or {}).get("targetAuthorUuid") or (reaction_payload.get("raw") or {}).get("targetAuthor") or (reaction_payload.get("raw") or {}).get("targetAuthorNumber") or "" ), remove=bool(reaction_payload.get("remove")), payload=reaction_payload.get("raw") or {}, ) except Exception as exc: self.log.warning("signal raw reaction history apply failed: %s", exc) try: await self.ur.xmpp.client.apply_external_reaction( identifier.user, identifier, source_service="signal", emoji=str(reaction_payload.get("emoji") or ""), remove=bool(reaction_payload.get("remove")), upstream_message_id="", upstream_ts=int(reaction_payload.get("target_ts") or 0), actor=(source_uuid or source_number or ""), payload=reaction_payload.get("raw") or {}, ) except Exception as exc: self.log.warning("signal raw reaction relay to XMPP failed: %s", exc) transport.update_runtime_state( self.service, last_inbound_ok_ts=int(time.time() * 1000), last_inbound_exception_type="", last_inbound_exception_message="", ) return text = _extract_signal_text(payload, str(data_message.get("message") or "").strip()) if not text: return ts_raw = ( envelope.get("timestamp") or envelope.get("serverReceivedTimestamp") or int(time.time() * 1000) ) try: ts = int(ts_raw) except Exception: ts = int(time.time() * 1000) source_message_id = str( envelope.get("serverGuid") or envelope.get("guid") or envelope.get("timestamp") or ts ).strip() sender_key = source_uuid or source_number or (identifiers[0].identifier if identifiers else "") source_chat_id = source_number or source_uuid or sender_key reply_ref = reply_sync.extract_reply_ref(self.service, payload) for identifier in identifiers: session = await history.get_chat_session(identifier.user, identifier) reply_target = await reply_sync.resolve_reply_target( identifier.user, session, reply_ref, ) exists = await sync_to_async( lambda: Message.objects.filter( user=identifier.user, session=session, source_service=self.service, source_message_id=source_message_id, ).exists() )() if exists: continue local_message = await history.store_message( session=session, sender=sender_key, text=text, ts=ts, outgoing=False, source_service=self.service, source_message_id=source_message_id, source_chat_id=source_chat_id, reply_to=reply_target, reply_source_service=str(reply_ref.get("reply_source_service") or ""), reply_source_message_id=str( reply_ref.get("reply_source_message_id") or "" ), message_meta={}, ) await self.ur.message_received( self.service, identifier=identifier, text=text, ts=ts, payload=payload, local_message=local_message, ) transport.update_runtime_state( self.service, last_inbound_ok_ts=int(time.time() * 1000), last_inbound_exception_type="", last_inbound_exception_message="", ) async def _raw_receive_loop(self): signal_number = str(getattr(settings, "SIGNAL_NUMBER", "") or "").strip() if not signal_number: return uri = f"ws://{SIGNAL_URL}/v1/receive/{signal_number}" while not self._stopping: try: async with websockets.connect(uri, ping_interval=None) as websocket: async for raw_message in websocket: await self._process_raw_inbound_event(raw_message) except asyncio.CancelledError: raise except Exception as exc: self.log.warning("signal raw-receive loop error: %s", exc) await asyncio.sleep(2) def start(self): self.log.info("Signal client starting...") self.client._event_loop = self.loop # Start background command processing loop if not self._command_task or self._command_task.done(): self._command_task = self.loop.create_task(self._command_loop()) if not self._raw_receive_task or self._raw_receive_task.done(): self._raw_receive_task = self.loop.create_task(self._raw_receive_loop()) # Use direct websocket receive loop as primary ingestion path. # signalbot's internal receive consumer can compete for the same stream # and starve inbound events in this deployment, so we keep it disabled.