Increase platform abstraction cohesion
This commit is contained in:
@@ -193,6 +193,85 @@ def _extract_signal_reaction(envelope):
|
||||
}
|
||||
|
||||
|
||||
def _extract_signal_edit(envelope):
|
||||
paths = [
|
||||
("dataMessage", "editMessage"),
|
||||
("syncMessage", "sentMessage", "editMessage"),
|
||||
("syncMessage", "editMessage"),
|
||||
]
|
||||
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
|
||||
|
||||
target_ts = node.get("targetSentTimestamp")
|
||||
if target_ts is None:
|
||||
target_ts = node.get("targetTimestamp")
|
||||
if target_ts is None:
|
||||
target_ts = node.get("targetTs")
|
||||
try:
|
||||
target_ts = int(target_ts)
|
||||
except Exception:
|
||||
target_ts = 0
|
||||
if target_ts <= 0:
|
||||
return None
|
||||
|
||||
data_message = node.get("dataMessage") or node.get("message") or {}
|
||||
new_text = ""
|
||||
if isinstance(data_message, dict):
|
||||
for key in ("message", "text", "body", "caption"):
|
||||
value = str(data_message.get(key) or "").strip()
|
||||
if value:
|
||||
new_text = value
|
||||
break
|
||||
if not new_text:
|
||||
new_text = str(node.get("message") or "").strip()
|
||||
if not new_text:
|
||||
return None
|
||||
|
||||
return {
|
||||
"target_ts": target_ts,
|
||||
"new_text": new_text,
|
||||
"raw": dict(node),
|
||||
}
|
||||
|
||||
|
||||
def _extract_signal_delete(envelope):
|
||||
paths = [
|
||||
("dataMessage", "delete"),
|
||||
("dataMessage", "remoteDelete"),
|
||||
("syncMessage", "sentMessage", "delete"),
|
||||
("syncMessage", "delete"),
|
||||
]
|
||||
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
|
||||
target_ts = node.get("targetSentTimestamp")
|
||||
if target_ts is None:
|
||||
target_ts = node.get("targetTimestamp")
|
||||
if target_ts is None:
|
||||
target_ts = node.get("targetTs")
|
||||
try:
|
||||
target_ts = int(target_ts)
|
||||
except Exception:
|
||||
target_ts = 0
|
||||
if target_ts <= 0:
|
||||
return None
|
||||
return {
|
||||
"target_ts": target_ts,
|
||||
"raw": dict(node),
|
||||
}
|
||||
|
||||
|
||||
def _extract_signal_text(raw_payload, default_text=""):
|
||||
text = str(default_text or "").strip()
|
||||
if text:
|
||||
@@ -1299,6 +1378,8 @@ class SignalClient(ClientBase):
|
||||
destination_number,
|
||||
)
|
||||
reaction_payload = _extract_signal_reaction(envelope)
|
||||
edit_payload = _extract_signal_edit(envelope)
|
||||
delete_payload = _extract_signal_delete(envelope)
|
||||
if identifiers and isinstance(reaction_payload, dict):
|
||||
source_uuid = str(
|
||||
envelope.get("sourceUuid") or envelope.get("source") or ""
|
||||
@@ -1343,6 +1424,61 @@ class SignalClient(ClientBase):
|
||||
self.log.warning(
|
||||
"signal raw sync reaction relay to XMPP failed: %s", exc
|
||||
)
|
||||
if identifiers and isinstance(edit_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_message_edit(
|
||||
identifier.user,
|
||||
identifier,
|
||||
target_message_id="",
|
||||
target_ts=int(edit_payload.get("target_ts") or 0),
|
||||
new_text=str(edit_payload.get("new_text") or ""),
|
||||
source_service="signal",
|
||||
actor=(source_uuid or source_number or ""),
|
||||
payload=edit_payload.get("raw") or {},
|
||||
)
|
||||
except Exception as exc:
|
||||
self.log.warning(
|
||||
"signal raw sync edit history apply 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
|
||||
if identifiers and isinstance(delete_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_message_delete(
|
||||
identifier.user,
|
||||
identifier,
|
||||
target_message_id="",
|
||||
target_ts=int(delete_payload.get("target_ts") or 0),
|
||||
source_service="signal",
|
||||
actor=(source_uuid or source_number or ""),
|
||||
payload=delete_payload.get("raw") or {},
|
||||
)
|
||||
except Exception as exc:
|
||||
self.log.warning(
|
||||
"signal raw sync delete history apply 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
|
||||
if identifiers and text:
|
||||
ts_raw = (
|
||||
sync_sent_message.get("timestamp")
|
||||
@@ -1427,8 +1563,14 @@ class SignalClient(ClientBase):
|
||||
|
||||
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
|
||||
edit_payload = _extract_signal_edit(envelope)
|
||||
delete_payload = _extract_signal_delete(envelope)
|
||||
if (not identifiers) and (
|
||||
isinstance(reaction_payload, dict)
|
||||
or isinstance(edit_payload, dict)
|
||||
or isinstance(delete_payload, dict)
|
||||
):
|
||||
# Sync events from our own linked device can arrive with source=our
|
||||
# account and destination=<contact>. Resolve by destination as fallback.
|
||||
destination_uuid = str(
|
||||
envelope.get("destinationServiceId")
|
||||
@@ -1497,6 +1639,49 @@ class SignalClient(ClientBase):
|
||||
last_inbound_exception_message="",
|
||||
)
|
||||
return
|
||||
if isinstance(edit_payload, dict):
|
||||
for identifier in identifiers:
|
||||
try:
|
||||
await history.apply_message_edit(
|
||||
identifier.user,
|
||||
identifier,
|
||||
target_message_id="",
|
||||
target_ts=int(edit_payload.get("target_ts") or 0),
|
||||
new_text=str(edit_payload.get("new_text") or ""),
|
||||
source_service="signal",
|
||||
actor=(source_uuid or source_number or ""),
|
||||
payload=edit_payload.get("raw") or {},
|
||||
)
|
||||
except Exception as exc:
|
||||
self.log.warning("signal raw edit history apply 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
|
||||
if isinstance(delete_payload, dict):
|
||||
for identifier in identifiers:
|
||||
try:
|
||||
await history.apply_message_delete(
|
||||
identifier.user,
|
||||
identifier,
|
||||
target_message_id="",
|
||||
target_ts=int(delete_payload.get("target_ts") or 0),
|
||||
source_service="signal",
|
||||
actor=(source_uuid or source_number or ""),
|
||||
payload=delete_payload.get("raw") or {},
|
||||
)
|
||||
except Exception as exc:
|
||||
self.log.warning("signal raw delete history apply 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:
|
||||
|
||||
@@ -776,6 +776,14 @@ async def send_message_raw(
|
||||
Unified outbound send path used by models/views/UR.
|
||||
"""
|
||||
service_key = _service_key(service)
|
||||
if _capability_checks_enabled() and not supports(service_key, "send"):
|
||||
reason = unsupported_reason(service_key, "send")
|
||||
log.warning(
|
||||
"capability-check failed service=%s feature=send: %s",
|
||||
service_key,
|
||||
reason,
|
||||
)
|
||||
return False
|
||||
if service_key == "signal":
|
||||
prepared_attachments = await prepare_outbound_attachments(
|
||||
service_key, attachments or []
|
||||
|
||||
@@ -141,10 +141,14 @@ class XMPPComponent(ComponentXMPP):
|
||||
self._reconnect_task = None
|
||||
self._reconnect_delay_seconds = 1.0
|
||||
self._reconnect_delay_max_seconds = 30.0
|
||||
self._connect_inflight = False
|
||||
self._session_live = False
|
||||
|
||||
self.log = logs.get_logger("XMPP")
|
||||
|
||||
super().__init__(jid, secret, server, port)
|
||||
# Use one reconnect strategy (our backoff loop) to avoid reconnect churn.
|
||||
self.auto_reconnect = False
|
||||
# Register chat state plugins
|
||||
register_stanza_plugin(Message, Active)
|
||||
register_stanza_plugin(Message, Composing)
|
||||
@@ -178,6 +182,21 @@ class XMPPComponent(ComponentXMPP):
|
||||
self.add_event_handler("chatstate_inactive", self.on_chatstate_inactive)
|
||||
self.add_event_handler("chatstate_gone", self.on_chatstate_gone)
|
||||
|
||||
def _user_xmpp_domain(self):
|
||||
domain = str(getattr(settings, "XMPP_USER_DOMAIN", "") or "").strip()
|
||||
if domain:
|
||||
return domain
|
||||
component_jid = str(getattr(settings, "XMPP_JID", "") or "").strip()
|
||||
if "." in component_jid:
|
||||
return component_jid.split(".", 1)[1]
|
||||
configured_domain = str(getattr(settings, "DOMAIN", "") or "").strip()
|
||||
if configured_domain:
|
||||
return configured_domain
|
||||
return str(getattr(settings, "XMPP_ADDRESS", "") or "").strip()
|
||||
|
||||
def _user_jid(self, username):
|
||||
return f"{username}@{self._user_xmpp_domain()}"
|
||||
|
||||
async def enable_carbons(self):
|
||||
"""Enable XMPP Message Carbons (XEP-0280)"""
|
||||
try:
|
||||
@@ -827,25 +846,33 @@ class XMPPComponent(ComponentXMPP):
|
||||
|
||||
async def session_start(self, *args):
|
||||
self.log.info("XMPP session started")
|
||||
self._session_live = True
|
||||
self._connect_inflight = False
|
||||
self._reconnect_delay_seconds = 1.0
|
||||
if self._reconnect_task and not self._reconnect_task.done():
|
||||
self._reconnect_task.cancel()
|
||||
self._reconnect_task = None
|
||||
await self.enable_carbons()
|
||||
# This client connects as an external component, not a user client;
|
||||
# XEP-0280 (carbons) is client-scoped and not valid here.
|
||||
self.log.debug("Skipping carbons enable for component session")
|
||||
|
||||
async def _reconnect_loop(self):
|
||||
try:
|
||||
while True:
|
||||
delay = float(self._reconnect_delay_seconds)
|
||||
await asyncio.sleep(delay)
|
||||
if self._session_live or self._connect_inflight:
|
||||
return
|
||||
try:
|
||||
self.log.info("XMPP reconnect attempt delay_s=%.1f", delay)
|
||||
self._connect_inflight = True
|
||||
connected = self.connect()
|
||||
if connected is False:
|
||||
raise RuntimeError("connect returned false")
|
||||
return
|
||||
except Exception as exc:
|
||||
self.log.warning("XMPP reconnect attempt failed: %s", exc)
|
||||
self._connect_inflight = False
|
||||
self._reconnect_delay_seconds = min(
|
||||
self._reconnect_delay_max_seconds,
|
||||
max(1.0, float(self._reconnect_delay_seconds) * 2.0),
|
||||
@@ -853,6 +880,8 @@ class XMPPComponent(ComponentXMPP):
|
||||
except asyncio.CancelledError:
|
||||
return
|
||||
finally:
|
||||
if not self._session_live:
|
||||
self._connect_inflight = False
|
||||
self._reconnect_task = None
|
||||
|
||||
def _schedule_reconnect(self):
|
||||
@@ -864,6 +893,8 @@ class XMPPComponent(ComponentXMPP):
|
||||
"""
|
||||
Handles XMPP disconnection and triggers a reconnect loop.
|
||||
"""
|
||||
self._session_live = False
|
||||
self._connect_inflight = False
|
||||
self.log.warning(
|
||||
"XMPP disconnected, scheduling reconnect attempt in %.1fs",
|
||||
float(self._reconnect_delay_seconds),
|
||||
@@ -1576,7 +1607,7 @@ class XMPPComponent(ComponentXMPP):
|
||||
f"{person_identifier.person.name.lower()}|"
|
||||
f"{person_identifier.service}@{settings.XMPP_JID}"
|
||||
)
|
||||
recipient_jid = f"{user.username}@{settings.XMPP_ADDRESS}"
|
||||
recipient_jid = self._user_jid(user.username)
|
||||
await self.send_xmpp_reaction(
|
||||
recipient_jid,
|
||||
sender_jid,
|
||||
@@ -1625,7 +1656,7 @@ class XMPPComponent(ComponentXMPP):
|
||||
f"{person_identifier.person.name.lower()}|"
|
||||
f"{person_identifier.service}@{settings.XMPP_JID}"
|
||||
)
|
||||
recipient_jid = f"{user.username}@{settings.XMPP_ADDRESS}"
|
||||
recipient_jid = self._user_jid(user.username)
|
||||
await self.send_chat_state(recipient_jid, sender_jid, started)
|
||||
|
||||
async def send_from_external(
|
||||
@@ -1640,7 +1671,7 @@ class XMPPComponent(ComponentXMPP):
|
||||
"""Handles sending XMPP messages with text and attachments."""
|
||||
|
||||
sender_jid = f"{person_identifier.person.name.lower()}|{person_identifier.service}@{settings.XMPP_JID}"
|
||||
recipient_jid = f"{person_identifier.user.username}@{settings.XMPP_ADDRESS}"
|
||||
recipient_jid = self._user_jid(person_identifier.user.username)
|
||||
if is_outgoing_message:
|
||||
xmpp_id = await self.send_xmpp_message(
|
||||
recipient_jid,
|
||||
@@ -1767,22 +1798,45 @@ class XMPPComponent(ComponentXMPP):
|
||||
class XMPPClient(ClientBase):
|
||||
def __init__(self, ur, *args, **kwargs):
|
||||
super().__init__(ur, *args, **kwargs)
|
||||
self.client = XMPPComponent(
|
||||
ur,
|
||||
jid=settings.XMPP_JID,
|
||||
secret=settings.XMPP_SECRET,
|
||||
server=settings.XMPP_ADDRESS,
|
||||
port=settings.XMPP_PORT,
|
||||
)
|
||||
self._enabled = True
|
||||
self.client = None
|
||||
jid = str(getattr(settings, "XMPP_JID", "") or "").strip()
|
||||
secret = str(getattr(settings, "XMPP_SECRET", "") or "").strip()
|
||||
server = str(getattr(settings, "XMPP_ADDRESS", "") or "").strip()
|
||||
port = int(getattr(settings, "XMPP_PORT", 8888) or 8888)
|
||||
missing = []
|
||||
if not jid:
|
||||
missing.append("XMPP_JID")
|
||||
if not secret:
|
||||
missing.append("XMPP_SECRET")
|
||||
if not server:
|
||||
missing.append("XMPP_ADDRESS")
|
||||
if missing:
|
||||
self._enabled = False
|
||||
self.log.warning(
|
||||
"XMPP client disabled due to missing configuration: %s",
|
||||
", ".join(missing),
|
||||
)
|
||||
|
||||
self.client.register_plugin("xep_0030") # Service Discovery
|
||||
self.client.register_plugin("xep_0004") # Data Forms
|
||||
self.client.register_plugin("xep_0060") # PubSub
|
||||
self.client.register_plugin("xep_0199") # XMPP Ping
|
||||
self.client.register_plugin("xep_0085") # Chat State Notifications
|
||||
self.client.register_plugin("xep_0363") # HTTP File Upload
|
||||
if self._enabled:
|
||||
self.client = XMPPComponent(
|
||||
ur,
|
||||
jid=jid,
|
||||
secret=secret,
|
||||
server=server,
|
||||
port=port,
|
||||
)
|
||||
|
||||
self.client.register_plugin("xep_0030") # Service Discovery
|
||||
self.client.register_plugin("xep_0004") # Data Forms
|
||||
self.client.register_plugin("xep_0060") # PubSub
|
||||
self.client.register_plugin("xep_0199") # XMPP Ping
|
||||
self.client.register_plugin("xep_0085") # Chat State Notifications
|
||||
self.client.register_plugin("xep_0363") # HTTP File Upload
|
||||
|
||||
def start(self):
|
||||
if not self._enabled or self.client is None:
|
||||
return
|
||||
self.log.info("XMPP client starting...")
|
||||
|
||||
# ensure slixmpp uses the same asyncio loop as the router
|
||||
@@ -1791,7 +1845,11 @@ class XMPPClient(ClientBase):
|
||||
self.client.connect()
|
||||
|
||||
async def start_typing_for_person(self, user, person_identifier):
|
||||
if self.client is None:
|
||||
return
|
||||
await self.client.send_typing_for_person(user, person_identifier, True)
|
||||
|
||||
async def stop_typing_for_person(self, user, person_identifier):
|
||||
if self.client is None:
|
||||
return
|
||||
await self.client.send_typing_for_person(user, person_identifier, False)
|
||||
|
||||
Reference in New Issue
Block a user