1500 lines
59 KiB
Python
1500 lines
59 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,
|
|
)
|
|
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.
|