Files
GIA/core/clients/signal.py

1511 lines
60 KiB
Python

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 ""
),
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")
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,
)
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)
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
reaction_payload = _extract_signal_reaction(envelope)
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 ""),
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.