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 @@
@@ -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(),
}