Improve insights and continue WhatsApp implementation

This commit is contained in:
2026-02-15 23:02:51 +00:00
parent b23af9bc7f
commit 88224d972c
13 changed files with 628 additions and 81 deletions

View File

@@ -169,6 +169,11 @@ urlpatterns = [
compose.ComposeThread.as_view(), compose.ComposeThread.as_view(),
name="compose_thread", name="compose_thread",
), ),
path(
"compose/media/blob/",
compose.ComposeMediaBlob.as_view(),
name="compose_media_blob",
),
path( path(
"compose/widget/contacts/", "compose/widget/contacts/",
compose.ComposeContactsDropdown.as_view(), compose.ComposeContactsDropdown.as_view(),

View File

@@ -1,7 +1,7 @@
import asyncio import asyncio
import json import json
import os import os
from urllib.parse import urlparse from urllib.parse import quote_plus, urlparse
import aiohttp import aiohttp
from asgiref.sync import sync_to_async from asgiref.sync import sync_to_async
@@ -10,7 +10,7 @@ from django.urls import reverse
from signalbot import Command, Context, SignalBot from signalbot import Command, Context, SignalBot
from core.clients import ClientBase, signalapi from core.clients import ClientBase, signalapi
from core.messaging import ai, history, natural, replies, utils from core.messaging import ai, history, media_bridge, natural, replies, utils
from core.models import Chat, Manipulation, PersonIdentifier, QueuedMessage from core.models import Chat, Manipulation, PersonIdentifier, QueuedMessage
from core.util import logs from core.util import logs
@@ -339,6 +339,7 @@ class HandleMessage(Command):
# Handle attachments across multiple Signal payload variants. # Handle attachments across multiple Signal payload variants.
attachment_list = _extract_attachments(raw) attachment_list = _extract_attachments(raw)
xmpp_attachments = [] xmpp_attachments = []
compose_media_urls = []
# Asynchronously fetch all attachments # Asynchronously fetch all attachments
log.info(f"ATTACHMENT LIST {attachment_list}") log.info(f"ATTACHMENT LIST {attachment_list}")
@@ -366,10 +367,25 @@ class HandleMessage(Command):
"size": fetched["size"], "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))}"
)
if (not text) and compose_media_urls:
text = "\n".join(compose_media_urls)
# Forward incoming Signal messages to XMPP and apply mutate rules. # Forward incoming Signal messages to XMPP and apply mutate rules.
identifier_text_overrides = {}
for identifier in identifiers: for identifier in identifiers:
user = identifier.user user = identifier.user
session_key = (identifier.user.id, identifier.person.id)
mutate_manips = await sync_to_async(list)( mutate_manips = await sync_to_async(list)(
Manipulation.objects.filter( Manipulation.objects.filter(
@@ -381,6 +397,7 @@ class HandleMessage(Command):
) )
) )
if mutate_manips: if mutate_manips:
uploaded_urls = []
for manip in mutate_manips: for manip in mutate_manips:
prompt = replies.generate_mutate_reply_prompt( prompt = replies.generate_mutate_reply_prompt(
text, text,
@@ -393,24 +410,36 @@ class HandleMessage(Command):
log.info( log.info(
f"Sending {len(xmpp_attachments)} attachments from Signal to XMPP." f"Sending {len(xmpp_attachments)} attachments from Signal to XMPP."
) )
await self.ur.xmpp.client.send_from_external( uploaded_urls = await self.ur.xmpp.client.send_from_external(
user, user,
identifier, identifier,
result, result,
is_outgoing_message, is_outgoing_message,
attachments=xmpp_attachments, attachments=xmpp_attachments,
) )
resolved_text = 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: else:
log.info( log.info(
f"Sending {len(xmpp_attachments)} attachments from Signal to XMPP." f"Sending {len(xmpp_attachments)} attachments from Signal to XMPP."
) )
await self.ur.xmpp.client.send_from_external( uploaded_urls = await self.ur.xmpp.client.send_from_external(
user, user,
identifier, identifier,
text, text,
is_outgoing_message, is_outgoing_message,
attachments=xmpp_attachments, attachments=xmpp_attachments,
) )
resolved_text = 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 # Persist message history for every resolved identifier, even when no
# manipulations are active, so manual chat windows stay complete. # manipulations are active, so manual chat windows stay complete.
@@ -425,11 +454,12 @@ class HandleMessage(Command):
session_cache[session_key] = chat_session session_cache[session_key] = chat_session
sender_key = source_uuid or source_number or identifier_candidates[0] sender_key = source_uuid or source_number or identifier_candidates[0]
message_key = (chat_session.id, ts, sender_key) message_key = (chat_session.id, ts, sender_key)
message_text = identifier_text_overrides.get(session_key, text)
if message_key not in stored_messages: if message_key not in stored_messages:
await history.store_message( await history.store_message(
session=chat_session, session=chat_session,
sender=sender_key, sender=sender_key,
text=text, text=message_text,
ts=ts, ts=ts,
outgoing=is_from_bot, outgoing=is_from_bot,
) )

View File

@@ -136,7 +136,9 @@ def request_pairing(service: str, device_name: str = ""):
service_key, service_key,
pair_device=device, pair_device=device,
pair_requested_at=int(time.time()), pair_requested_at=int(time.time()),
warning="Waiting for runtime pairing QR.", pair_status="pending",
pair_qr="",
pair_request_source="web",
) )

View File

@@ -1,6 +1,7 @@
import asyncio import asyncio
import re import re
import time import time
from urllib.parse import quote_plus
import aiohttp import aiohttp
from asgiref.sync import sync_to_async from asgiref.sync import sync_to_async
@@ -33,6 +34,10 @@ class WhatsAppClient(ClientBase):
self._chat_presence = None self._chat_presence = None
self._chat_presence_media = None self._chat_presence_media = None
self._last_pair_request = 0 self._last_pair_request = 0
self._next_qr_probe_at = 0.0
self._qr_handler_registered = False
self._qr_handler_supported = False
self._event_hook_callable = False
self.enabled = bool( self.enabled = bool(
str(getattr(settings, "WHATSAPP_ENABLED", "false")).lower() str(getattr(settings, "WHATSAPP_ENABLED", "false")).lower()
@@ -54,6 +59,7 @@ class WhatsAppClient(ClientBase):
else "" else ""
), ),
accounts=[], accounts=[],
last_event="init",
) )
def _publish_state(self, **updates): def _publish_state(self, **updates):
@@ -88,6 +94,8 @@ class WhatsAppClient(ClientBase):
connected=False, connected=False,
warning=f"Neonize not available: {exc}", warning=f"Neonize not available: {exc}",
accounts=[], accounts=[],
last_event="neonize_import_failed",
last_error=str(exc),
) )
self.log.warning("whatsapp neonize import failed: %s", exc) self.log.warning("whatsapp neonize import failed: %s", exc)
return return
@@ -101,6 +109,8 @@ class WhatsAppClient(ClientBase):
connected=False, connected=False,
warning="Failed to initialize Neonize client.", warning="Failed to initialize Neonize client.",
accounts=[], accounts=[],
last_event="client_init_failed",
last_error="client_none",
) )
return return
@@ -108,6 +118,7 @@ class WhatsAppClient(ClientBase):
try: try:
await self._maybe_await(self._client.connect()) await self._maybe_await(self._client.connect())
await self._after_connect_probe()
except asyncio.CancelledError: except asyncio.CancelledError:
raise raise
except Exception as exc: except Exception as exc:
@@ -115,13 +126,22 @@ class WhatsAppClient(ClientBase):
connected=False, connected=False,
warning=f"WhatsApp connect failed: {exc}", warning=f"WhatsApp connect failed: {exc}",
accounts=[], accounts=[],
last_event="connect_failed",
last_error=str(exc),
) )
self.log.warning("whatsapp connect failed: %s", exc) self.log.warning("whatsapp connect failed: %s", exc)
return return
# Keep task alive so state/callbacks remain active. # Keep task alive so state/callbacks remain active.
next_heartbeat_at = 0.0
while not self._stopping: while not self._stopping:
now = time.time()
if now >= next_heartbeat_at:
self._publish_state(runtime_seen_at=int(now))
next_heartbeat_at = now + 5.0
self._mark_qr_wait_timeout()
await self._sync_pair_request() await self._sync_pair_request()
await self._probe_pending_qr(now)
await asyncio.sleep(1) await asyncio.sleep(1)
async def _sync_pair_request(self): async def _sync_pair_request(self):
@@ -134,7 +154,12 @@ class WhatsAppClient(ClientBase):
connected=False, connected=False,
pair_qr="", pair_qr="",
warning="Waiting for WhatsApp QR from Neonize.", warning="Waiting for WhatsApp QR from Neonize.",
last_event="pair_request_seen",
pair_status="pending",
last_error="",
pair_reconnect_attempted_at=int(time.time()),
) )
self._next_qr_probe_at = time.time()
if self._client is None: if self._client is None:
return return
@@ -143,38 +168,147 @@ class WhatsAppClient(ClientBase):
if hasattr(self._client, "disconnect"): if hasattr(self._client, "disconnect"):
await self._maybe_await(self._client.disconnect()) await self._maybe_await(self._client.disconnect())
except Exception as exc: except Exception as exc:
self._publish_state(
last_event="pair_disconnect_failed",
last_error=str(exc),
)
self.log.warning("whatsapp disconnect before pairing failed: %s", exc) self.log.warning("whatsapp disconnect before pairing failed: %s", exc)
try: try:
await self._maybe_await(self._client.connect()) await self._maybe_await(self._client.connect())
self._publish_state(
last_event="pair_refresh_connected",
pair_refresh_connected_at=int(time.time()),
)
await self._after_connect_probe()
except Exception as exc: except Exception as exc:
self._publish_state( self._publish_state(
connected=False, connected=False,
warning=f"WhatsApp pairing refresh failed: {exc}", warning=f"WhatsApp pairing refresh failed: {exc}",
last_event="pair_refresh_failed",
last_error=str(exc),
) )
self.log.warning("whatsapp pairing refresh failed: %s", exc) self.log.warning("whatsapp pairing refresh failed: %s", exc)
def _register_event(self, event_cls, callback): async def _probe_pending_qr(self, now_ts: float):
if event_cls is None: state = transport.get_runtime_state(self.service)
status = str(state.get("pair_status") or "").strip().lower()
if status != "pending":
return return
if str(state.get("pair_qr") or "").strip():
return
if now_ts < float(self._next_qr_probe_at or 0.0):
return
await self._after_connect_probe()
latest = transport.get_runtime_state(self.service)
if str(latest.get("pair_qr") or "").strip():
self._next_qr_probe_at = now_ts + 30.0
return
error_text = str(latest.get("last_error") or "").strip().lower()
# Neonize may report "client is nil" for a few seconds after connect.
if "client is nil" in error_text:
self._next_qr_probe_at = now_ts + 2.0
return
self._next_qr_probe_at = now_ts + 5.0
async def _after_connect_probe(self):
if self._client is None: if self._client is None:
return return
now_ts = int(time.time())
try:
if hasattr(self._client, "is_connected"):
connected_value = await self._maybe_await(self._client.is_connected())
if connected_value:
self._connected = True
self._publish_state(
connected=True,
pair_status="connected",
last_event="connected_probe",
connected_at=now_ts,
)
except Exception as exc:
self._publish_state(
last_event="connected_probe_failed",
last_error=str(exc),
)
# Neonize does not always emit QR callbacks after reconnect. Try explicit
# QR-link fetch when available to surface pair data to the UI.
try:
if hasattr(self._client, "get_contact_qr_link"):
qr_link = await self._maybe_await(self._client.get_contact_qr_link())
qr_payload = self._decode_qr_payload(qr_link)
self._publish_state(last_qr_probe_at=now_ts)
if qr_payload:
self._last_qr_payload = qr_payload
self._publish_state(
connected=False,
pair_qr=qr_payload,
warning="Scan QR in WhatsApp Linked Devices.",
last_event="qr_probe_success",
pair_status="qr_ready",
qr_received_at=now_ts,
qr_probe_result="ok",
last_error="",
)
else:
self._publish_state(
last_event="qr_probe_empty",
qr_probe_result="empty",
)
except Exception as exc:
self._publish_state(
last_event="qr_probe_failed",
qr_probe_result="error",
last_error=str(exc),
last_qr_probe_at=now_ts,
)
def _register_event(self, event_cls, callback):
if event_cls is None:
return False
if self._client is None:
return False
event_hook = getattr(self._client, "event", None) event_hook = getattr(self._client, "event", None)
if not callable(event_hook): if not callable(event_hook):
return self._event_hook_callable = False
return False
self._event_hook_callable = True
try: try:
decorator = event_hook(event_cls) decorator = event_hook(event_cls)
decorator(callback) decorator(callback)
return True
except Exception as exc: except Exception as exc:
self.log.warning( self.log.warning(
"whatsapp event registration failed (%s): %s", "whatsapp event registration failed (%s): %s",
getattr(event_cls, "__name__", str(event_cls)), getattr(event_cls, "__name__", str(event_cls)),
exc, exc,
) )
self._publish_state(
last_event="event_registration_failed",
last_error=str(exc),
)
return False
def _register_qr_handler(self): def _register_qr_handler(self):
if self._client is None or not hasattr(self._client, "qr"): if self._client is None:
self._qr_handler_supported = False
self._qr_handler_registered = False
self._publish_state(
qr_handler_supported=False,
qr_handler_registered=False,
)
return return
if not hasattr(self._client, "qr"):
self._qr_handler_supported = False
self._qr_handler_registered = False
self._publish_state(
qr_handler_supported=False,
qr_handler_registered=False,
last_event="qr_api_missing",
)
return
self._qr_handler_supported = True
async def on_qr(client, raw_payload): async def on_qr(client, raw_payload):
qr_payload = self._decode_qr_payload(raw_payload) qr_payload = self._decode_qr_payload(raw_payload)
@@ -185,11 +319,28 @@ class WhatsAppClient(ClientBase):
connected=False, connected=False,
pair_qr=qr_payload, pair_qr=qr_payload,
warning="Scan QR in WhatsApp Linked Devices.", warning="Scan QR in WhatsApp Linked Devices.",
last_event="qr_handler",
pair_status="qr_ready",
qr_received_at=int(time.time()),
last_error="",
) )
try: try:
self._client.qr(on_qr) self._client.qr(on_qr)
self._qr_handler_registered = True
self._publish_state(
qr_handler_supported=True,
qr_handler_registered=True,
last_event="qr_handler_registered",
)
except Exception as exc: except Exception as exc:
self._qr_handler_registered = False
self._publish_state(
qr_handler_supported=True,
qr_handler_registered=False,
last_event="qr_handler_registration_failed",
last_error=str(exc),
)
self.log.warning("whatsapp qr handler registration failed: %s", exc) self.log.warning("whatsapp qr handler registration failed: %s", exc)
def _decode_qr_payload(self, raw_payload): def _decode_qr_payload(self, raw_payload):
@@ -237,6 +388,18 @@ class WhatsAppClient(ClientBase):
qr_ev = getattr(wa_events, "QREv", None) qr_ev = getattr(wa_events, "QREv", None)
self._register_qr_handler() self._register_qr_handler()
support = {
"connected_ev": bool(connected_ev),
"pair_ev": bool(pair_ev),
"qr_ev": bool(qr_ev),
"message_ev": bool(message_ev),
"receipt_ev": bool(receipt_ev),
}
self._publish_state(
event_hook_callable=bool(getattr(self._client, "event", None)),
event_support=support,
last_event="event_handlers_scanned",
)
if connected_ev is not None: if connected_ev is not None:
@@ -248,6 +411,10 @@ class WhatsAppClient(ClientBase):
warning="", warning="",
accounts=[account] if account else [self.client_name], accounts=[account] if account else [self.client_name],
pair_qr="", pair_qr="",
last_event="connected",
pair_status="connected",
connected_at=int(time.time()),
last_error="",
) )
self._register_event(connected_ev, on_connected) self._register_event(connected_ev, on_connected)
@@ -289,6 +456,10 @@ class WhatsAppClient(ClientBase):
self._publish_state( self._publish_state(
pair_qr=qr_payload, pair_qr=qr_payload,
warning="Scan QR in WhatsApp Linked Devices.", warning="Scan QR in WhatsApp Linked Devices.",
last_event="pair_status_qr",
pair_status="qr_ready",
qr_received_at=int(time.time()),
last_error="",
) )
status_raw = self._pluck(event, "Status") status_raw = self._pluck(event, "Status")
status_text = str(status_raw or "").strip().lower() status_text = str(status_raw or "").strip().lower()
@@ -300,11 +471,18 @@ class WhatsAppClient(ClientBase):
warning="", warning="",
accounts=[account] if account else [self.client_name], accounts=[account] if account else [self.client_name],
pair_qr="", pair_qr="",
last_event="pair_status_success",
pair_status="connected",
connected_at=int(time.time()),
last_error="",
) )
elif status_text in {"1", "error"}: elif status_text in {"1", "error"}:
error_text = str(self._pluck(event, "Error") or "").strip() error_text = str(self._pluck(event, "Error") or "").strip()
self._publish_state( self._publish_state(
warning=error_text or "WhatsApp pairing failed. Retry scan.", warning=error_text or "WhatsApp pairing failed. Retry scan.",
last_event="pair_status_error",
pair_status="error",
last_error=error_text or "unknown_pair_error",
) )
self._register_event(pair_ev, on_pair_status) self._register_event(pair_ev, on_pair_status)
@@ -320,10 +498,35 @@ class WhatsAppClient(ClientBase):
connected=False, connected=False,
pair_qr=qr_payload, pair_qr=qr_payload,
warning="Scan QR in WhatsApp Linked Devices.", warning="Scan QR in WhatsApp Linked Devices.",
last_event="qr_event",
pair_status="qr_ready",
qr_received_at=int(time.time()),
last_error="",
) )
self._register_event(qr_ev, on_qr_event) self._register_event(qr_ev, on_qr_event)
def _mark_qr_wait_timeout(self):
state = transport.get_runtime_state(self.service)
if str(state.get("pair_status") or "").strip().lower() != "pending":
return
requested_at = int(state.get("pair_requested_at") or 0)
qr_received_at = int(state.get("qr_received_at") or 0)
if requested_at <= 0 or qr_received_at > 0:
return
now = int(time.time())
age = now - requested_at
# Avoid spamming writes while still surfacing a clear timeout state.
if age < 15 or (age % 10) != 0:
return
self._publish_state(
last_event="pair_waiting_no_qr",
warning=(
"Waiting for WhatsApp QR from Neonize. "
"No QR callback received yet."
),
)
async def _maybe_await(self, value): async def _maybe_await(self, value):
if asyncio.iscoroutine(value): if asyncio.iscoroutine(value):
return await value return await value
@@ -549,25 +752,37 @@ class WhatsAppClient(ClientBase):
} }
for identifier in identifiers: for identifier in identifiers:
session = await history.get_chat_session(identifier.user, identifier) uploaded_urls = await self.ur.xmpp.client.send_from_external(
await history.store_message(
session=session,
sender=str(sender or chat or ""),
text=text,
ts=ts,
outgoing=False,
)
await self.ur.xmpp.client.send_from_external(
identifier.user, identifier.user,
identifier, identifier,
text, text,
is_outgoing_message=False, is_outgoing_message=False,
attachments=xmpp_attachments, attachments=xmpp_attachments,
) )
display_text = text
if (not display_text) and uploaded_urls:
display_text = "\n".join(uploaded_urls)
if (not display_text) and attachments:
media_urls = [
self._blob_key_to_compose_url((att or {}).get("blob_key"))
for att in attachments
]
media_urls = [url for url in media_urls if url]
if media_urls:
display_text = "\n".join(media_urls)
session = await history.get_chat_session(identifier.user, identifier)
await history.store_message(
session=session,
sender=str(sender or chat or ""),
text=display_text,
ts=ts,
outgoing=False,
)
await self.ur.message_received( await self.ur.message_received(
self.service, self.service,
identifier=identifier, identifier=identifier,
text=text, text=display_text,
ts=ts, ts=ts,
payload=payload, payload=payload,
) )
@@ -699,6 +914,12 @@ class WhatsAppClient(ClientBase):
return f"{digits}@s.whatsapp.net" return f"{digits}@s.whatsapp.net"
return raw return raw
def _blob_key_to_compose_url(self, blob_key):
key = str(blob_key or "").strip()
if not key:
return ""
return f"/compose/media/blob/?key={quote_plus(key)}"
async def _fetch_attachment_payload(self, attachment): async def _fetch_attachment_payload(self, attachment):
blob_key = (attachment or {}).get("blob_key") blob_key = (attachment or {}).get("blob_key")
if blob_key: if blob_key:

View File

@@ -795,7 +795,15 @@ class XMPPComponent(ComponentXMPP):
# self.log.info(f"Upload service: {upload_service}") # self.log.info(f"Upload service: {upload_service}")
upload_service_jid = "share.zm.is" upload_service_jid = str(
getattr(settings, "XMPP_UPLOAD_SERVICE", "")
or getattr(settings, "XMPP_UPLOAD_JID", "")
).strip()
if not upload_service_jid:
self.log.error(
"XMPP upload service is not configured. Set XMPP_UPLOAD_SERVICE."
)
return None
try: try:
slot = await self["xep_0363"].request_slot( slot = await self["xep_0363"].request_slot(
@@ -1109,7 +1117,7 @@ class XMPPComponent(ComponentXMPP):
self.log.error( self.log.error(
f"Upload failed: {response.status} {await response.text()}" f"Upload failed: {response.status} {await response.text()}"
) )
return return None
self.log.info( self.log.info(
f"Successfully uploaded {att['filename']} to {upload_url}" f"Successfully uploaded {att['filename']} to {upload_url}"
) )
@@ -1118,9 +1126,11 @@ class XMPPComponent(ComponentXMPP):
await self.send_xmpp_message( await self.send_xmpp_message(
recipient_jid, sender_jid, upload_url, attachment_url=upload_url recipient_jid, sender_jid, upload_url, attachment_url=upload_url
) )
return upload_url
except Exception as e: except Exception as e:
self.log.error(f"Error uploading {att['filename']} to XMPP: {e}") self.log.error(f"Error uploading {att['filename']} to XMPP: {e}")
return None
async def send_xmpp_message( async def send_xmpp_message(
self, recipient_jid, sender_jid, body_text, attachment_url=None self, recipient_jid, sender_jid, body_text, attachment_url=None
@@ -1177,21 +1187,22 @@ class XMPPComponent(ComponentXMPP):
await self.send_xmpp_message(recipient_jid, sender_jid, text) await self.send_xmpp_message(recipient_jid, sender_jid, text)
if not attachments: if not attachments:
return # No attachments to process return [] # No attachments to process
# Step 2: Request upload slots concurrently # Step 2: Request upload slots concurrently
valid_uploads = await self.request_upload_slots(recipient_jid, attachments) valid_uploads = await self.request_upload_slots(recipient_jid, attachments)
self.log.info("Got upload slots") self.log.info("Got upload slots")
if not valid_uploads: if not valid_uploads:
self.log.warning("No valid upload slots obtained.") self.log.warning("No valid upload slots obtained.")
# return return []
# Step 3: Upload each file and send its message immediately after upload # Step 3: Upload each file and send its message immediately after upload
upload_tasks = [ upload_tasks = [
self.upload_and_send(att, slot, recipient_jid, sender_jid) self.upload_and_send(att, slot, recipient_jid, sender_jid)
for att, slot in valid_uploads for att, slot in valid_uploads
] ]
await asyncio.gather(*upload_tasks) # Upload files concurrently uploaded_urls = await asyncio.gather(*upload_tasks) # Upload files concurrently
return [url for url in uploaded_urls if url]
class XMPPClient(ClientBase): class XMPPClient(ClientBase):

View File

@@ -500,6 +500,82 @@
}); });
}; };
window.giaPrepareWindowAnchor = function (trigger) {
if (!trigger || !trigger.getBoundingClientRect) {
window.giaWindowAnchor = null;
return;
}
const rect = trigger.getBoundingClientRect();
window.giaWindowAnchor = {
left: rect.left,
right: rect.right,
top: rect.top,
bottom: rect.bottom,
width: rect.width,
height: rect.height,
ts: Date.now(),
};
};
window.giaPositionFloatingWindow = function (windowEl) {
if (!windowEl) {
return;
}
const isMobile = window.matchMedia("(max-width: 768px)").matches;
const margin = 12;
const rect = windowEl.getBoundingClientRect();
const anchor = window.giaWindowAnchor || null;
windowEl.style.position = "fixed";
if (isMobile) {
const centeredLeftViewport = Math.max(
margin,
Math.round((window.innerWidth - rect.width) / 2)
);
const centeredTopViewport = Math.max(
margin,
Math.round((window.innerHeight - rect.height) / 2)
);
windowEl.style.left = centeredLeftViewport + "px";
windowEl.style.top = centeredTopViewport + "px";
windowEl.style.right = "auto";
windowEl.style.bottom = "auto";
windowEl.style.transform = "none";
windowEl.setAttribute("tabindex", "-1");
if (typeof windowEl.focus === "function") {
windowEl.focus({preventScroll: true});
}
if (typeof windowEl.scrollIntoView === "function") {
windowEl.scrollIntoView({block: "center", inline: "center", behavior: "smooth"});
}
window.giaWindowAnchor = null;
return;
}
if (!anchor || (Date.now() - anchor.ts) > 10000) {
return;
}
const desiredLeftViewport = anchor.left;
const desiredTopViewport = anchor.bottom + 6;
const maxLeftViewport = window.innerWidth - rect.width - margin;
const maxTopViewport = window.innerHeight - rect.height - margin;
const boundedLeftViewport = Math.max(
margin,
Math.min(desiredLeftViewport, maxLeftViewport)
);
const boundedTopViewport = Math.max(
margin,
Math.min(desiredTopViewport, maxTopViewport)
);
windowEl.style.left = boundedLeftViewport + "px";
windowEl.style.top = boundedTopViewport + "px";
windowEl.style.right = "auto";
windowEl.style.bottom = "auto";
windowEl.style.transform = "none";
window.giaWindowAnchor = null;
};
document.addEventListener("click", function (event) { document.addEventListener("click", function (event) {
const trigger = event.target.closest(".js-widget-spawn-trigger"); const trigger = event.target.closest(".js-widget-spawn-trigger");
if (!trigger) { if (!trigger) {
@@ -515,6 +591,15 @@
document.body.addEventListener("htmx:afterSwap", function (event) { document.body.addEventListener("htmx:afterSwap", function (event) {
const target = (event && event.target) || document; const target = (event && event.target) || document;
window.giaEnableWidgetSpawnButtons(target); window.giaEnableWidgetSpawnButtons(target);
const targetId = (target && target.id) || "";
if (targetId === "windows-here") {
const floatingWindow = target.querySelector(".floating-window");
if (floatingWindow) {
window.setTimeout(function () {
window.giaPositionFloatingWindow(floatingWindow);
}, 0);
}
}
}); });
</script> </script>
{% block outer_content %} {% block outer_content %}
@@ -527,7 +612,7 @@
{% endblock %} {% endblock %}
<div id="modals-here"> <div id="modals-here">
</div> </div>
<div id="windows-here"> <div id="windows-here" style="z-index: 120;">
</div> </div>
<div id="widgets-here" style="display: none;"> <div id="widgets-here" style="display: none;">
</div> </div>

View File

@@ -1,6 +1,9 @@
<div id="widget"> <div id="widget">
<div id="widget-{{ unique }}" class="grid-stack-item" {% block widget_options %}{% if widget_options is None %}gs-w="6" gs-h="1" gs-y="20" gs-x="0"{% else %}{% autoescape off %}{{ widget_options }}{% endautoescape %}{% endif %}{% endblock %}> <div id="widget-{{ unique }}" class="grid-stack-item" {% block widget_options %}{% if widget_options is None %}gs-w="6" gs-h="1" gs-y="20" gs-x="0"{% else %}{% autoescape off %}{{ widget_options }}{% endautoescape %}{% endif %}{% endblock %}>
<div class="grid-stack-item-content"> <div class="grid-stack-item-content">
<span class="gia-widget-focus-indicator" aria-hidden="true">
<i class="{{ widget_icon|default:'fa-solid fa-circle' }}"></i>
</span>
<nav class="panel"> <nav class="panel">
<p class="panel-heading" style="padding: .2em; line-height: .5em;"> <p class="panel-heading" style="padding: .2em; line-height: .5em;">
@@ -27,6 +30,36 @@
</div> </div>
</div> </div>
<style>
#widget-{{ unique }} .grid-stack-item-content {
position: relative;
}
#widget-{{ unique }} .gia-widget-focus-indicator {
position: absolute;
left: 0.32rem;
top: 50%;
width: 1rem;
height: 1rem;
border-radius: 50%;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 0.7rem;
color: #2f5f9f;
background: rgba(235, 244, 255, 0.92);
border: 1px solid rgba(47, 95, 159, 0.3);
opacity: 0;
transform: translateY(-50%) scale(0.92);
transition: opacity 120ms ease, transform 120ms ease;
pointer-events: none;
z-index: 3;
}
#widget-{{ unique }}.is-widget-active .gia-widget-focus-indicator {
opacity: 1;
transform: translateY(-50%) scale(1);
}
</style>
<script> <script>
{% block custom_script %} {% block custom_script %}
{% endblock %} {% endblock %}
@@ -34,43 +67,22 @@
document.dispatchEvent(widget_event); document.dispatchEvent(widget_event);
(function () { (function () {
var widgetRoot = document.getElementById("widget-{{ unique }}"); var widgetRoot = document.getElementById("widget-{{ unique }}");
var iconClass = "{{ widget_icon|default:'fa-solid fa-arrows-minimize'|escapejs }}"; if (!widgetRoot) {
function decorateHandle() { return;
if (!widgetRoot) {
return true;
}
var handles = widgetRoot.querySelectorAll(".ui-resizable-se");
if (!handles.length) {
handles = widgetRoot.querySelectorAll(".ui-resizable-handle");
}
if (!handles.length) {
return false;
}
handles.forEach(function (handle) {
if (handle.dataset.iconApplied === "1") {
return;
}
handle.dataset.iconApplied = "1";
handle.style.display = "flex";
handle.style.alignItems = "center";
handle.style.justifyContent = "center";
handle.style.overflow = "hidden";
var icon = document.createElement("i");
icon.className = iconClass + " has-text-grey-light";
icon.style.fontSize = "0.65rem";
icon.style.opacity = "0.65";
icon.style.pointerEvents = "none";
handle.appendChild(icon);
});
return true;
} }
var attempts = 0;
var timer = window.setInterval(function () { function setActive() {
attempts += 1; document.querySelectorAll(".grid-stack-item.is-widget-active").forEach(function (node) {
if (decorateHandle() || attempts > 10) { if (node !== widgetRoot) {
window.clearInterval(timer); node.classList.remove("is-widget-active");
} }
}, 80); });
widgetRoot.classList.add("is-widget-active");
}
widgetRoot.addEventListener("mousedown", setActive);
widgetRoot.addEventListener("focusin", setActive);
window.setTimeout(setActive, 0);
})(); })();
</script> </script>
{% block custom_end %} {% block custom_end %}

View File

@@ -30,9 +30,22 @@
{% if metric.group == group_key %} {% if metric.group == group_key %}
<article class="message is-light" style="margin-bottom: 0.6rem;"> <article class="message is-light" style="margin-bottom: 0.6rem;">
<div class="message-body"> <div class="message-body">
<p><strong>{{ metric.title }}</strong>: {{ metric.value|default:"-" }}</p> <h3 class="is-size-6 has-text-weight-semibold" style="margin-bottom: 0.45rem;">{{ metric.title }}</h3>
<p><strong>Calculation:</strong> {{ metric.calculation }}</p>
<p><strong>Psychological Read:</strong> {{ metric.psychology }}</p> <p class="is-size-7 has-text-grey has-text-weight-semibold" style="margin-bottom: 0.15rem;">
Current Value
</p>
<p style="margin-bottom: 0.55rem;">{{ metric.value|default:"-" }}</p>
<p class="is-size-7 has-text-grey has-text-weight-semibold" style="margin-bottom: 0.15rem;">
How It Is Calculated
</p>
<p style="margin-bottom: 0.55rem;">{{ metric.calculation }}</p>
<p class="is-size-7 has-text-grey has-text-weight-semibold" style="margin-bottom: 0.15rem;">
Psychological Interpretation
</p>
<p>{{ metric.psychology }}</p>
</div> </div>
</article> </article>
{% endif %} {% endif %}

View File

@@ -52,7 +52,7 @@
{% if render_mode == "page" %} {% if render_mode == "page" %}
<a class="button is-light is-rounded" href="{{ compose_workspace_url }}"> <a class="button is-light is-rounded" href="{{ compose_workspace_url }}">
<span class="icon is-small"><i class="fa-solid fa-table-cells-large"></i></span> <span class="icon is-small"><i class="fa-solid fa-table-cells-large"></i></span>
<span>Open In Workspace</span> <span>Chat Workspace</span>
</a> </a>
{% endif %} {% endif %}
</div> </div>
@@ -204,6 +204,7 @@
class="compose-image" class="compose-image"
src="{{ image_url }}" src="{{ image_url }}"
alt="Attachment" alt="Attachment"
referrerpolicy="no-referrer"
loading="lazy" loading="lazy"
decoding="async"> decoding="async">
</figure> </figure>
@@ -214,6 +215,7 @@
class="compose-image" class="compose-image"
src="{{ msg.image_url }}" src="{{ msg.image_url }}"
alt="Attachment" alt="Attachment"
referrerpolicy="no-referrer"
loading="lazy" loading="lazy"
decoding="async"> decoding="async">
</figure> </figure>
@@ -390,7 +392,7 @@
#{{ panel_id }}-lightbox.compose-lightbox { #{{ panel_id }}-lightbox.compose-lightbox {
position: fixed; position: fixed;
inset: 0; inset: 0;
z-index: 160; z-index: 12050;
background: rgba(10, 12, 16, 0.82); background: rgba(10, 12, 16, 0.82);
display: flex; display: flex;
align-items: center; align-items: center;
@@ -661,7 +663,7 @@
width: min(40rem, calc(100% - 1rem)); width: min(40rem, calc(100% - 1rem));
margin-top: 0; margin-top: 0;
z-index: 35; z-index: 35;
overflow: auto; overflow: visible;
} }
#{{ panel_id }} .compose-ai-popover-backdrop { #{{ panel_id }} .compose-ai-popover-backdrop {
position: absolute; position: absolute;
@@ -687,6 +689,7 @@
background: #fff; background: #fff;
padding: 0.65rem; padding: 0.65rem;
box-shadow: 0 12px 24px rgba(0, 0, 0, 0.08); box-shadow: 0 12px 24px rgba(0, 0, 0, 0.08);
overflow: visible;
} }
#{{ panel_id }} .compose-ai-card.is-active { #{{ panel_id }} .compose-ai-card.is-active {
display: block; display: block;
@@ -863,9 +866,9 @@
word-break: break-word; word-break: break-word;
} }
#{{ panel_id }} .compose-qi-doc-dot { #{{ panel_id }} .compose-qi-doc-dot {
width: 0.5rem; width: 0.64rem;
height: 0.5rem; height: 0.64rem;
min-width: 0.5rem; min-width: 0.64rem;
border-radius: 50%; border-radius: 50%;
border: 0; border: 0;
padding: 0; padding: 0;
@@ -874,13 +877,65 @@
cursor: help; cursor: help;
opacity: 0.85; opacity: 0.85;
transform: translateY(0.02rem); transform: translateY(0.02rem);
position: relative;
z-index: 1;
}
#{{ panel_id }} .compose-qi-doc-dot::after {
content: attr(data-tooltip);
position: absolute;
left: 50%;
bottom: calc(100% + 0.42rem);
transform: translate(-50%, 0.18rem);
width: min(21rem, 75vw);
max-width: 21rem;
padding: 0.42rem 0.5rem;
border-radius: 7px;
background: rgba(31, 39, 53, 0.96);
color: #f5f8ff;
font-size: 0.67rem;
line-height: 1.3;
text-align: left;
white-space: normal;
box-shadow: 0 8px 22px rgba(7, 10, 17, 0.28);
opacity: 0;
visibility: hidden;
pointer-events: none;
transition: opacity 85ms ease, transform 85ms ease, visibility 85ms linear;
transition-delay: 30ms;
}
#{{ panel_id }} .compose-qi-doc-dot::before {
content: "";
position: absolute;
left: 50%;
bottom: calc(100% + 0.1rem);
transform: translateX(-50%);
width: 0;
height: 0;
border-left: 0.3rem solid transparent;
border-right: 0.3rem solid transparent;
border-top: 0.36rem solid rgba(31, 39, 53, 0.96);
opacity: 0;
visibility: hidden;
pointer-events: none;
transition: opacity 85ms ease, visibility 85ms linear;
transition-delay: 30ms;
} }
#{{ panel_id }} .compose-qi-doc-dot:hover, #{{ panel_id }} .compose-qi-doc-dot:hover,
#{{ panel_id }} .compose-qi-doc-dot:focus-visible { #{{ panel_id }} .compose-qi-doc-dot:focus-visible {
background: #9ab1cc;
opacity: 1; opacity: 1;
outline: 1px solid rgba(52, 101, 164, 0.45); outline: 1px solid rgba(52, 101, 164, 0.45);
outline-offset: 1px; outline-offset: 1px;
} }
#{{ panel_id }} .compose-qi-doc-dot:hover::after,
#{{ panel_id }} .compose-qi-doc-dot:focus-visible::after,
#{{ panel_id }} .compose-qi-doc-dot:hover::before,
#{{ panel_id }} .compose-qi-doc-dot:focus-visible::before {
opacity: 1;
visibility: visible;
transform: translate(-50%, 0);
transition-delay: 0ms;
}
#{{ panel_id }} .compose-qi-row-meta { #{{ panel_id }} .compose-qi-row-meta {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@@ -1242,6 +1297,7 @@
img.className = "compose-image"; img.className = "compose-image";
img.src = String(candidateUrl); img.src = String(candidateUrl);
img.alt = "Attachment"; img.alt = "Attachment";
img.referrerPolicy = "no-referrer";
img.loading = "lazy"; img.loading = "lazy";
img.decoding = "async"; img.decoding = "async";
figure.appendChild(img); figure.appendChild(img);
@@ -1290,11 +1346,7 @@
} }
img.dataset.fallbackBound = "1"; img.dataset.fallbackBound = "1";
img.addEventListener("error", function () { img.addEventListener("error", function () {
const figure = img.closest(".compose-media"); img.classList.add("is-image-load-failed");
if (figure) {
figure.remove();
}
refresh();
}); });
img.addEventListener("load", function () { img.addEventListener("load", function () {
if (fallback) { if (fallback) {
@@ -1921,7 +1973,7 @@
const dot = document.createElement("button"); const dot = document.createElement("button");
dot.type = "button"; dot.type = "button";
dot.className = "compose-qi-doc-dot"; dot.className = "compose-qi-doc-dot";
dot.title = String(tooltipText || ""); dot.setAttribute("data-tooltip", String(tooltipText || ""));
dot.setAttribute("aria-label", "Explain " + String(titleText || "metric")); dot.setAttribute("aria-label", "Explain " + String(titleText || "metric"));
dot.addEventListener("click", function (ev) { dot.addEventListener("click", function (ev) {
ev.preventDefault(); ev.preventDefault();

View File

@@ -168,6 +168,7 @@
hx-get="{{ action.url }}" hx-get="{{ action.url }}"
hx-target="{{ action.target }}" hx-target="{{ action.target }}"
hx-swap="innerHTML" hx-swap="innerHTML"
{% if action.target == "#windows-here" %}onclick="window.giaPrepareWindowAnchor(this);"{% endif %}
title="{{ action.title }}"> title="{{ action.title }}">
<span class="icon"><i class="{{ action.icon }}"></i></span> <span class="icon"><i class="{{ action.icon }}"></i></span>
</button> </button>

View File

@@ -4,6 +4,13 @@
{% if object.warning %} {% if object.warning %}
<p class="is-size-7" style="margin-top: 0.6rem;">{{ object.warning }}</p> <p class="is-size-7" style="margin-top: 0.6rem;">{{ object.warning }}</p>
{% endif %} {% endif %}
{% if object.debug_lines %}
<article class="notification is-light" style="margin-top: 0.6rem; margin-bottom: 0;">
<p><strong>Runtime Debug</strong></p>
<pre class="is-size-7" style="white-space: pre-wrap; margin: 0.4rem 0 0;">{% for line in object.debug_lines %}{{ line }}
{% endfor %}</pre>
</article>
{% endif %}
{% else %} {% else %}
<article class="notification is-warning is-light" style="margin-bottom: 0;"> <article class="notification is-warning is-light" style="margin-bottom: 0;">
<p><strong>WhatsApp QR Not Ready.</strong></p> <p><strong>WhatsApp QR Not Ready.</strong></p>
@@ -26,6 +33,13 @@
<input type="hidden" name="refresh" value="1" /> <input type="hidden" name="refresh" value="1" />
</form> </form>
{% endif %} {% endif %}
{% if object.debug_lines %}
<article class="notification is-light" style="margin-top: 0.6rem; margin-bottom: 0;">
<p><strong>Runtime Debug</strong></p>
<pre class="is-size-7" style="white-space: pre-wrap; margin: 0.4rem 0 0;">{% for line in object.debug_lines %}{{ line }}
{% endfor %}</pre>
</article>
{% endif %}
</article> </article>
{% endif %} {% endif %}
</div> </div>

View File

@@ -5,13 +5,19 @@ import json
import re import re
import time import time
from datetime import datetime, timezone as dt_timezone from datetime import datetime, timezone as dt_timezone
from urllib.parse import urlencode, urlparse from urllib.parse import quote_plus, urlencode, urlparse
from asgiref.sync import async_to_sync from asgiref.sync import async_to_sync
from django.conf import settings
from django.contrib.auth.mixins import LoginRequiredMixin from django.contrib.auth.mixins import LoginRequiredMixin
from django.core import signing from django.core import signing
from django.core.cache import cache from django.core.cache import cache
from django.http import HttpResponseBadRequest, JsonResponse from django.http import (
HttpResponse,
HttpResponseBadRequest,
HttpResponseNotFound,
JsonResponse,
)
from django.shortcuts import get_object_or_404, render from django.shortcuts import get_object_or_404, render
from django.urls import NoReverseMatch, reverse from django.urls import NoReverseMatch, reverse
from django.utils import timezone as dj_timezone from django.utils import timezone as dj_timezone
@@ -19,6 +25,7 @@ from django.views import View
from core.clients import transport from core.clients import transport
from core.messaging import ai as ai_runner from core.messaging import ai as ai_runner
from core.messaging import media_bridge
from core.messaging.utils import messages_to_string from core.messaging.utils import messages_to_string
from core.models import ( from core.models import (
AI, AI,
@@ -127,9 +134,31 @@ def _looks_like_image_url(url_value: str) -> bool:
return False return False
parsed = urlparse(url_value) parsed = urlparse(url_value)
path = str(parsed.path or "").lower() path = str(parsed.path or "").lower()
if path.endswith("/compose/media/blob/"):
return True
return path.endswith(IMAGE_EXTENSIONS) return path.endswith(IMAGE_EXTENSIONS)
def _is_xmpp_share_url(url_value: str) -> bool:
if not url_value:
return False
parsed = urlparse(url_value)
host = str(parsed.netloc or "").strip().lower()
configured = str(
getattr(settings, "XMPP_UPLOAD_SERVICE", "")
or getattr(settings, "XMPP_UPLOAD_JID", "")
).strip().lower()
if not configured:
return False
configured_host = configured
if "://" in configured:
configured_host = (urlparse(configured).netloc or configured_host).lower()
if "@" in configured_host:
configured_host = configured_host.split("@", 1)[-1]
configured_host = configured_host.split("/", 1)[0]
return host == configured_host
def _image_url_from_text(text_value: str) -> str: def _image_url_from_text(text_value: str) -> str:
urls = _image_urls_from_text(text_value) urls = _image_urls_from_text(text_value)
return urls[0] if urls else "" return urls[0] if urls else ""
@@ -175,12 +204,23 @@ def _extract_attachment_image_urls(blob) -> list[str]:
filename = str(blob.get("filename") or blob.get("fileName") or "").strip() filename = str(blob.get("filename") or blob.get("fileName") or "").strip()
image_hint = content_type.startswith("image/") or _looks_like_image_name(filename) image_hint = content_type.startswith("image/") or _looks_like_image_name(filename)
direct_urls = []
for key in ("url", "source_url", "download_url", "proxy_url", "href", "uri"): for key in ("url", "source_url", "download_url", "proxy_url", "href", "uri"):
normalized = _clean_url(blob.get(key)) normalized = _clean_url(blob.get(key))
if not normalized: if not normalized:
continue continue
if image_hint or _looks_like_image_url(normalized): if (
urls.append(normalized) image_hint
or _looks_like_image_url(normalized)
or _is_xmpp_share_url(normalized)
):
direct_urls.append(normalized)
urls.extend(direct_urls)
blob_key = str(blob.get("blob_key") or "").strip()
# Prefer source-hosted URLs (for example share.zm.is) and use blob fallback only
# when no usable direct URL exists.
if blob_key and image_hint and not direct_urls:
urls.append(f"/compose/media/blob/?key={quote_plus(blob_key)}")
nested = blob.get("attachments") nested = blob.get("attachments")
if isinstance(nested, list): if isinstance(nested, list):
@@ -1632,6 +1672,29 @@ class ComposeThread(LoginRequiredMixin, View):
return JsonResponse(payload) return JsonResponse(payload)
class ComposeMediaBlob(LoginRequiredMixin, View):
"""
Serve cached media blobs for authenticated compose image previews.
"""
def get(self, request):
blob_key = str(request.GET.get("key") or "").strip()
if not blob_key:
return HttpResponseBadRequest("Missing blob key.")
row = media_bridge.get_blob(blob_key)
if not row:
return HttpResponseNotFound("Blob not found.")
content = row.get("content") or b""
content_type = str(row.get("content_type") or "application/octet-stream")
filename = str(row.get("filename") or "attachment.bin")
response = HttpResponse(content, content_type=content_type)
response["Content-Length"] = str(len(content))
response["Content-Disposition"] = f'inline; filename="{filename}"'
return response
class ComposeDrafts(LoginRequiredMixin, View): class ComposeDrafts(LoginRequiredMixin, View):
def get(self, request): def get(self, request):
service = _default_service(request.GET.get("service")) service = _default_service(request.GET.get("service"))

View File

@@ -5,6 +5,7 @@ from mixins.views import ObjectList, ObjectRead
from core.clients import transport from core.clients import transport
from core.views.manage.permissions import SuperUserRequiredMixin from core.views.manage.permissions import SuperUserRequiredMixin
import time
class WhatsApp(SuperUserRequiredMixin, View): class WhatsApp(SuperUserRequiredMixin, View):
@@ -88,6 +89,41 @@ class WhatsAppAccountAdd(SuperUserRequiredMixin, ObjectRead):
"detail_url": reverse(self.detail_url_name, kwargs=detail_url_args), "detail_url": reverse(self.detail_url_name, kwargs=detail_url_args),
} }
def _debug_state(self):
state = transport.get_runtime_state(self.service)
now = int(time.time())
def _age(key: str) -> str:
try:
value = int(state.get(key) or 0)
except Exception:
value = 0
if value <= 0:
return "n/a"
return f"{max(0, now - value)}s ago"
qr_value = str(state.get("pair_qr") or "")
return [
f"connected={bool(state.get('connected'))}",
f"runtime_updated={_age('updated_at')}",
f"runtime_seen={_age('runtime_seen_at')}",
f"pair_requested={_age('pair_requested_at')}",
f"qr_received={_age('qr_received_at')}",
f"last_qr_probe={_age('last_qr_probe_at')}",
f"pair_status={state.get('pair_status') or '-'}",
f"pair_request_source={state.get('pair_request_source') or '-'}",
f"qr_probe_result={state.get('qr_probe_result') or '-'}",
f"qr_handler_supported={state.get('qr_handler_supported')}",
f"qr_handler_registered={state.get('qr_handler_registered')}",
f"event_hook_callable={state.get('event_hook_callable')}",
f"event_support={state.get('event_support') or {}}",
f"last_event={state.get('last_event') or '-'}",
f"last_error={state.get('last_error') or '-'}",
f"pair_qr_present={bool(qr_value)} len={len(qr_value)}",
f"accounts={state.get('accounts') or []}",
f"warning={state.get('warning') or '-'}",
]
def post(self, request, *args, **kwargs): def post(self, request, *args, **kwargs):
self.request = request self.request = request
if self._refresh_only() and request.htmx: if self._refresh_only() and request.htmx:
@@ -109,6 +145,7 @@ class WhatsAppAccountAdd(SuperUserRequiredMixin, ObjectRead):
"ok": True, "ok": True,
"image_b64": transport.image_bytes_to_base64(image_bytes), "image_b64": transport.image_bytes_to_base64(image_bytes),
"warning": transport.get_service_warning(self.service), "warning": transport.get_service_warning(self.service),
"debug_lines": self._debug_state(),
} }
except Exception as exc: except Exception as exc:
error_text = str(exc) error_text = str(exc)
@@ -118,4 +155,5 @@ class WhatsAppAccountAdd(SuperUserRequiredMixin, ObjectRead):
"device": device_name, "device": device_name,
"error": error_text, "error": error_text,
"warning": transport.get_service_warning(self.service), "warning": transport.get_service_warning(self.service),
"debug_lines": self._debug_state(),
} }