diff --git a/app/urls.py b/app/urls.py index df510de..8073e5c 100644 --- a/app/urls.py +++ b/app/urls.py @@ -169,6 +169,11 @@ urlpatterns = [ compose.ComposeThread.as_view(), name="compose_thread", ), + path( + "compose/media/blob/", + compose.ComposeMediaBlob.as_view(), + name="compose_media_blob", + ), path( "compose/widget/contacts/", compose.ComposeContactsDropdown.as_view(), diff --git a/core/clients/signal.py b/core/clients/signal.py index 92c6ba9..059617e 100644 --- a/core/clients/signal.py +++ b/core/clients/signal.py @@ -1,7 +1,7 @@ import asyncio import json import os -from urllib.parse import urlparse +from urllib.parse import quote_plus, urlparse import aiohttp from asgiref.sync import sync_to_async @@ -10,7 +10,7 @@ from django.urls import reverse from signalbot import Command, Context, SignalBot 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.util import logs @@ -339,6 +339,7 @@ class HandleMessage(Command): # 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}") @@ -366,10 +367,25 @@ class HandleMessage(Command): "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. + 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( @@ -381,6 +397,7 @@ class HandleMessage(Command): ) ) if mutate_manips: + uploaded_urls = [] for manip in mutate_manips: prompt = replies.generate_mutate_reply_prompt( text, @@ -393,24 +410,36 @@ class HandleMessage(Command): log.info( 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, identifier, result, is_outgoing_message, 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: log.info( 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, identifier, text, is_outgoing_message, 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 # manipulations are active, so manual chat windows stay complete. @@ -425,11 +454,12 @@ class HandleMessage(Command): session_cache[session_key] = chat_session sender_key = source_uuid or source_number or identifier_candidates[0] message_key = (chat_session.id, ts, sender_key) + message_text = identifier_text_overrides.get(session_key, text) if message_key not in stored_messages: await history.store_message( session=chat_session, sender=sender_key, - text=text, + text=message_text, ts=ts, outgoing=is_from_bot, ) diff --git a/core/clients/transport.py b/core/clients/transport.py index fda10dd..4e058bf 100644 --- a/core/clients/transport.py +++ b/core/clients/transport.py @@ -136,7 +136,9 @@ def request_pairing(service: str, device_name: str = ""): service_key, pair_device=device, pair_requested_at=int(time.time()), - warning="Waiting for runtime pairing QR.", + pair_status="pending", + pair_qr="", + pair_request_source="web", ) diff --git a/core/clients/whatsapp.py b/core/clients/whatsapp.py index b26f2b9..9ccebbc 100644 --- a/core/clients/whatsapp.py +++ b/core/clients/whatsapp.py @@ -1,6 +1,7 @@ import asyncio import re import time +from urllib.parse import quote_plus import aiohttp from asgiref.sync import sync_to_async @@ -33,6 +34,10 @@ class WhatsAppClient(ClientBase): self._chat_presence = None self._chat_presence_media = None 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( str(getattr(settings, "WHATSAPP_ENABLED", "false")).lower() @@ -54,6 +59,7 @@ class WhatsAppClient(ClientBase): else "" ), accounts=[], + last_event="init", ) def _publish_state(self, **updates): @@ -88,6 +94,8 @@ class WhatsAppClient(ClientBase): connected=False, warning=f"Neonize not available: {exc}", accounts=[], + last_event="neonize_import_failed", + last_error=str(exc), ) self.log.warning("whatsapp neonize import failed: %s", exc) return @@ -101,6 +109,8 @@ class WhatsAppClient(ClientBase): connected=False, warning="Failed to initialize Neonize client.", accounts=[], + last_event="client_init_failed", + last_error="client_none", ) return @@ -108,6 +118,7 @@ class WhatsAppClient(ClientBase): try: await self._maybe_await(self._client.connect()) + await self._after_connect_probe() except asyncio.CancelledError: raise except Exception as exc: @@ -115,13 +126,22 @@ class WhatsAppClient(ClientBase): connected=False, warning=f"WhatsApp connect failed: {exc}", accounts=[], + last_event="connect_failed", + last_error=str(exc), ) self.log.warning("whatsapp connect failed: %s", exc) return # Keep task alive so state/callbacks remain active. + next_heartbeat_at = 0.0 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._probe_pending_qr(now) await asyncio.sleep(1) async def _sync_pair_request(self): @@ -134,7 +154,12 @@ class WhatsAppClient(ClientBase): connected=False, pair_qr="", 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: return @@ -143,38 +168,147 @@ class WhatsAppClient(ClientBase): if hasattr(self._client, "disconnect"): await self._maybe_await(self._client.disconnect()) 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) try: 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: self._publish_state( connected=False, 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) - def _register_event(self, event_cls, callback): - if event_cls is None: + async def _probe_pending_qr(self, now_ts: float): + state = transport.get_runtime_state(self.service) + status = str(state.get("pair_status") or "").strip().lower() + if status != "pending": 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: 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) if not callable(event_hook): - return + self._event_hook_callable = False + return False + self._event_hook_callable = True try: decorator = event_hook(event_cls) decorator(callback) + return True except Exception as exc: self.log.warning( "whatsapp event registration failed (%s): %s", getattr(event_cls, "__name__", str(event_cls)), exc, ) + self._publish_state( + last_event="event_registration_failed", + last_error=str(exc), + ) + return False 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 + 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): qr_payload = self._decode_qr_payload(raw_payload) @@ -185,11 +319,28 @@ class WhatsAppClient(ClientBase): connected=False, pair_qr=qr_payload, warning="Scan QR in WhatsApp Linked Devices.", + last_event="qr_handler", + pair_status="qr_ready", + qr_received_at=int(time.time()), + last_error="", ) try: 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: + 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) def _decode_qr_payload(self, raw_payload): @@ -237,6 +388,18 @@ class WhatsAppClient(ClientBase): qr_ev = getattr(wa_events, "QREv", None) 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: @@ -248,6 +411,10 @@ class WhatsAppClient(ClientBase): warning="", accounts=[account] if account else [self.client_name], pair_qr="", + last_event="connected", + pair_status="connected", + connected_at=int(time.time()), + last_error="", ) self._register_event(connected_ev, on_connected) @@ -289,6 +456,10 @@ class WhatsAppClient(ClientBase): self._publish_state( pair_qr=qr_payload, 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_text = str(status_raw or "").strip().lower() @@ -300,11 +471,18 @@ class WhatsAppClient(ClientBase): warning="", accounts=[account] if account else [self.client_name], pair_qr="", + last_event="pair_status_success", + pair_status="connected", + connected_at=int(time.time()), + last_error="", ) elif status_text in {"1", "error"}: error_text = str(self._pluck(event, "Error") or "").strip() self._publish_state( 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) @@ -320,10 +498,35 @@ class WhatsAppClient(ClientBase): connected=False, pair_qr=qr_payload, 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) + 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): if asyncio.iscoroutine(value): return await value @@ -549,25 +752,37 @@ class WhatsAppClient(ClientBase): } for identifier in identifiers: - session = await history.get_chat_session(identifier.user, identifier) - 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( + uploaded_urls = await self.ur.xmpp.client.send_from_external( identifier.user, identifier, text, is_outgoing_message=False, 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( self.service, identifier=identifier, - text=text, + text=display_text, ts=ts, payload=payload, ) @@ -699,6 +914,12 @@ class WhatsAppClient(ClientBase): return f"{digits}@s.whatsapp.net" 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): blob_key = (attachment or {}).get("blob_key") if blob_key: diff --git a/core/clients/xmpp.py b/core/clients/xmpp.py index f275054..34394aa 100644 --- a/core/clients/xmpp.py +++ b/core/clients/xmpp.py @@ -795,7 +795,15 @@ class XMPPComponent(ComponentXMPP): # 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: slot = await self["xep_0363"].request_slot( @@ -1109,7 +1117,7 @@ class XMPPComponent(ComponentXMPP): self.log.error( f"Upload failed: {response.status} {await response.text()}" ) - return + return None self.log.info( f"Successfully uploaded {att['filename']} to {upload_url}" ) @@ -1118,9 +1126,11 @@ class XMPPComponent(ComponentXMPP): await self.send_xmpp_message( recipient_jid, sender_jid, upload_url, attachment_url=upload_url ) + return upload_url except Exception as e: self.log.error(f"Error uploading {att['filename']} to XMPP: {e}") + return None async def send_xmpp_message( 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) if not attachments: - return # No attachments to process + return [] # No attachments to process # Step 2: Request upload slots concurrently valid_uploads = await self.request_upload_slots(recipient_jid, attachments) self.log.info("Got upload slots") if not valid_uploads: self.log.warning("No valid upload slots obtained.") - # return + return [] # Step 3: Upload each file and send its message immediately after upload upload_tasks = [ self.upload_and_send(att, slot, recipient_jid, sender_jid) 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): diff --git a/core/templates/base.html b/core/templates/base.html index e67c5a9..13737ec 100644 --- a/core/templates/base.html +++ b/core/templates/base.html @@ -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) { const trigger = event.target.closest(".js-widget-spawn-trigger"); if (!trigger) { @@ -515,6 +591,15 @@ document.body.addEventListener("htmx:afterSwap", function (event) { const target = (event && event.target) || document; 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); + } + } }); {% block outer_content %} @@ -527,7 +612,7 @@ {% endblock %}
-
+
diff --git a/core/templates/mixins/wm/widget.html b/core/templates/mixins/wm/widget.html index 44f1f66..2d089fc 100644 --- a/core/templates/mixins/wm/widget.html +++ b/core/templates/mixins/wm/widget.html @@ -1,6 +1,9 @@
+
+ + {% block custom_end %} diff --git a/core/templates/pages/ai-workspace-insight-help.html b/core/templates/pages/ai-workspace-insight-help.html index 7106c91..cc6d667 100644 --- a/core/templates/pages/ai-workspace-insight-help.html +++ b/core/templates/pages/ai-workspace-insight-help.html @@ -30,9 +30,22 @@ {% if metric.group == group_key %}
-

{{ metric.title }}: {{ metric.value|default:"-" }}

-

Calculation: {{ metric.calculation }}

-

Psychological Read: {{ metric.psychology }}

+

{{ metric.title }}

+ +

+ Current Value +

+

{{ metric.value|default:"-" }}

+ +

+ How It Is Calculated +

+

{{ metric.calculation }}

+ +

+ Psychological Interpretation +

+

{{ metric.psychology }}

{% endif %} diff --git a/core/templates/partials/compose-panel.html b/core/templates/partials/compose-panel.html index 7e64ec2..6c03a02 100644 --- a/core/templates/partials/compose-panel.html +++ b/core/templates/partials/compose-panel.html @@ -52,7 +52,7 @@ {% if render_mode == "page" %} - Open In Workspace + Chat Workspace {% endif %}
@@ -204,6 +204,7 @@ class="compose-image" src="{{ image_url }}" alt="Attachment" + referrerpolicy="no-referrer" loading="lazy" decoding="async"> @@ -214,6 +215,7 @@ class="compose-image" src="{{ msg.image_url }}" alt="Attachment" + referrerpolicy="no-referrer" loading="lazy" decoding="async"> @@ -390,7 +392,7 @@ #{{ panel_id }}-lightbox.compose-lightbox { position: fixed; inset: 0; - z-index: 160; + z-index: 12050; background: rgba(10, 12, 16, 0.82); display: flex; align-items: center; @@ -661,7 +663,7 @@ width: min(40rem, calc(100% - 1rem)); margin-top: 0; z-index: 35; - overflow: auto; + overflow: visible; } #{{ panel_id }} .compose-ai-popover-backdrop { position: absolute; @@ -687,6 +689,7 @@ background: #fff; padding: 0.65rem; box-shadow: 0 12px 24px rgba(0, 0, 0, 0.08); + overflow: visible; } #{{ panel_id }} .compose-ai-card.is-active { display: block; @@ -863,9 +866,9 @@ word-break: break-word; } #{{ panel_id }} .compose-qi-doc-dot { - width: 0.5rem; - height: 0.5rem; - min-width: 0.5rem; + width: 0.64rem; + height: 0.64rem; + min-width: 0.64rem; border-radius: 50%; border: 0; padding: 0; @@ -874,13 +877,65 @@ cursor: help; opacity: 0.85; 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:focus-visible { + background: #9ab1cc; opacity: 1; outline: 1px solid rgba(52, 101, 164, 0.45); 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 { display: inline-flex; align-items: center; @@ -1242,6 +1297,7 @@ img.className = "compose-image"; img.src = String(candidateUrl); img.alt = "Attachment"; + img.referrerPolicy = "no-referrer"; img.loading = "lazy"; img.decoding = "async"; figure.appendChild(img); @@ -1290,11 +1346,7 @@ } img.dataset.fallbackBound = "1"; img.addEventListener("error", function () { - const figure = img.closest(".compose-media"); - if (figure) { - figure.remove(); - } - refresh(); + img.classList.add("is-image-load-failed"); }); img.addEventListener("load", function () { if (fallback) { @@ -1921,7 +1973,7 @@ const dot = document.createElement("button"); dot.type = "button"; 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.addEventListener("click", function (ev) { ev.preventDefault(); diff --git a/core/templates/partials/osint/list-table.html b/core/templates/partials/osint/list-table.html index ce090f0..b0f782e 100644 --- a/core/templates/partials/osint/list-table.html +++ b/core/templates/partials/osint/list-table.html @@ -168,6 +168,7 @@ hx-get="{{ action.url }}" hx-target="{{ action.target }}" hx-swap="innerHTML" + {% if action.target == "#windows-here" %}onclick="window.giaPrepareWindowAnchor(this);"{% endif %} title="{{ action.title }}"> diff --git a/core/templates/partials/whatsapp-account-add.html b/core/templates/partials/whatsapp-account-add.html index 53fd924..10bb2cf 100644 --- a/core/templates/partials/whatsapp-account-add.html +++ b/core/templates/partials/whatsapp-account-add.html @@ -4,6 +4,13 @@ {% if object.warning %}

{{ object.warning }}

{% endif %} + {% if object.debug_lines %} +
+

Runtime Debug

+
{% for line in object.debug_lines %}{{ line }}
+{% endfor %}
+
+ {% endif %} {% else %}

WhatsApp QR Not Ready.

@@ -26,6 +33,13 @@ {% endif %} + {% if object.debug_lines %} +
+

Runtime Debug

+
{% for line in object.debug_lines %}{{ line }}
+{% endfor %}
+
+ {% endif %}
{% endif %}
diff --git a/core/views/compose.py b/core/views/compose.py index 0a6f0a1..4397a2f 100644 --- a/core/views/compose.py +++ b/core/views/compose.py @@ -5,13 +5,19 @@ import json import re import time 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 django.conf import settings from django.contrib.auth.mixins import LoginRequiredMixin from django.core import signing 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.urls import NoReverseMatch, reverse from django.utils import timezone as dj_timezone @@ -19,6 +25,7 @@ from django.views import View from core.clients import transport from core.messaging import ai as ai_runner +from core.messaging import media_bridge from core.messaging.utils import messages_to_string from core.models import ( AI, @@ -127,9 +134,31 @@ def _looks_like_image_url(url_value: str) -> bool: return False parsed = urlparse(url_value) path = str(parsed.path or "").lower() + if path.endswith("/compose/media/blob/"): + return True 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: urls = _image_urls_from_text(text_value) 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() 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"): normalized = _clean_url(blob.get(key)) if not normalized: continue - if image_hint or _looks_like_image_url(normalized): - urls.append(normalized) + if ( + 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") if isinstance(nested, list): @@ -1632,6 +1672,29 @@ class ComposeThread(LoginRequiredMixin, View): 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): def get(self, request): service = _default_service(request.GET.get("service")) diff --git a/core/views/whatsapp.py b/core/views/whatsapp.py index 28b7923..7175d68 100644 --- a/core/views/whatsapp.py +++ b/core/views/whatsapp.py @@ -5,6 +5,7 @@ from mixins.views import ObjectList, ObjectRead from core.clients import transport from core.views.manage.permissions import SuperUserRequiredMixin +import time class WhatsApp(SuperUserRequiredMixin, View): @@ -88,6 +89,41 @@ class WhatsAppAccountAdd(SuperUserRequiredMixin, ObjectRead): "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): self.request = request if self._refresh_only() and request.htmx: @@ -109,6 +145,7 @@ class WhatsAppAccountAdd(SuperUserRequiredMixin, ObjectRead): "ok": True, "image_b64": transport.image_bytes_to_base64(image_bytes), "warning": transport.get_service_warning(self.service), + "debug_lines": self._debug_state(), } except Exception as exc: error_text = str(exc) @@ -118,4 +155,5 @@ class WhatsAppAccountAdd(SuperUserRequiredMixin, ObjectRead): "device": device_name, "error": error_text, "warning": transport.get_service_warning(self.service), + "debug_lines": self._debug_state(), }