Improve insights and continue WhatsApp implementation
This commit is contained in:
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% block outer_content %}
|
||||
@@ -527,7 +612,7 @@
|
||||
{% endblock %}
|
||||
<div id="modals-here">
|
||||
</div>
|
||||
<div id="windows-here">
|
||||
<div id="windows-here" style="z-index: 120;">
|
||||
</div>
|
||||
<div id="widgets-here" style="display: none;">
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
<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 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">
|
||||
<p class="panel-heading" style="padding: .2em; line-height: .5em;">
|
||||
@@ -27,6 +30,36 @@
|
||||
</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>
|
||||
{% block custom_script %}
|
||||
{% endblock %}
|
||||
@@ -34,43 +67,22 @@
|
||||
document.dispatchEvent(widget_event);
|
||||
(function () {
|
||||
var widgetRoot = document.getElementById("widget-{{ unique }}");
|
||||
var iconClass = "{{ widget_icon|default:'fa-solid fa-arrows-minimize'|escapejs }}";
|
||||
function decorateHandle() {
|
||||
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;
|
||||
if (!widgetRoot) {
|
||||
return;
|
||||
}
|
||||
var attempts = 0;
|
||||
var timer = window.setInterval(function () {
|
||||
attempts += 1;
|
||||
if (decorateHandle() || attempts > 10) {
|
||||
window.clearInterval(timer);
|
||||
}
|
||||
}, 80);
|
||||
|
||||
function setActive() {
|
||||
document.querySelectorAll(".grid-stack-item.is-widget-active").forEach(function (node) {
|
||||
if (node !== widgetRoot) {
|
||||
node.classList.remove("is-widget-active");
|
||||
}
|
||||
});
|
||||
widgetRoot.classList.add("is-widget-active");
|
||||
}
|
||||
|
||||
widgetRoot.addEventListener("mousedown", setActive);
|
||||
widgetRoot.addEventListener("focusin", setActive);
|
||||
window.setTimeout(setActive, 0);
|
||||
})();
|
||||
</script>
|
||||
{% block custom_end %}
|
||||
|
||||
@@ -30,9 +30,22 @@
|
||||
{% if metric.group == group_key %}
|
||||
<article class="message is-light" style="margin-bottom: 0.6rem;">
|
||||
<div class="message-body">
|
||||
<p><strong>{{ metric.title }}</strong>: {{ metric.value|default:"-" }}</p>
|
||||
<p><strong>Calculation:</strong> {{ metric.calculation }}</p>
|
||||
<p><strong>Psychological Read:</strong> {{ metric.psychology }}</p>
|
||||
<h3 class="is-size-6 has-text-weight-semibold" style="margin-bottom: 0.45rem;">{{ metric.title }}</h3>
|
||||
|
||||
<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>
|
||||
</article>
|
||||
{% endif %}
|
||||
|
||||
@@ -52,7 +52,7 @@
|
||||
{% if render_mode == "page" %}
|
||||
<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>Open In Workspace</span>
|
||||
<span>Chat Workspace</span>
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
@@ -204,6 +204,7 @@
|
||||
class="compose-image"
|
||||
src="{{ image_url }}"
|
||||
alt="Attachment"
|
||||
referrerpolicy="no-referrer"
|
||||
loading="lazy"
|
||||
decoding="async">
|
||||
</figure>
|
||||
@@ -214,6 +215,7 @@
|
||||
class="compose-image"
|
||||
src="{{ msg.image_url }}"
|
||||
alt="Attachment"
|
||||
referrerpolicy="no-referrer"
|
||||
loading="lazy"
|
||||
decoding="async">
|
||||
</figure>
|
||||
@@ -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();
|
||||
|
||||
@@ -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 }}">
|
||||
<span class="icon"><i class="{{ action.icon }}"></i></span>
|
||||
</button>
|
||||
|
||||
@@ -4,6 +4,13 @@
|
||||
{% if object.warning %}
|
||||
<p class="is-size-7" style="margin-top: 0.6rem;">{{ object.warning }}</p>
|
||||
{% 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 %}
|
||||
<article class="notification is-warning is-light" style="margin-bottom: 0;">
|
||||
<p><strong>WhatsApp QR Not Ready.</strong></p>
|
||||
@@ -26,6 +33,13 @@
|
||||
<input type="hidden" name="refresh" value="1" />
|
||||
</form>
|
||||
{% 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>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user