Work on fixing bugs and reformat
This commit is contained in:
@@ -209,7 +209,7 @@ DEBUG_TOOLBAR_PANELS = [
|
|||||||
"debug_toolbar.panels.logging.LoggingPanel",
|
"debug_toolbar.panels.logging.LoggingPanel",
|
||||||
"debug_toolbar.panels.redirects.RedirectsPanel",
|
"debug_toolbar.panels.redirects.RedirectsPanel",
|
||||||
"debug_toolbar.panels.profiling.ProfilingPanel",
|
"debug_toolbar.panels.profiling.ProfilingPanel",
|
||||||
"cachalot.panels.CachalotPanel",
|
# "cachalot.panels.CachalotPanel", # Disabled due to compatibility issues with debug toolbar
|
||||||
]
|
]
|
||||||
|
|
||||||
from app.local_settings import * # noqa
|
from app.local_settings import * # noqa
|
||||||
|
|||||||
10
app/urls.py
10
app/urls.py
@@ -160,6 +160,16 @@ urlpatterns = [
|
|||||||
compose.ComposeSend.as_view(),
|
compose.ComposeSend.as_view(),
|
||||||
name="compose_send",
|
name="compose_send",
|
||||||
),
|
),
|
||||||
|
path(
|
||||||
|
"compose/cancel-send/",
|
||||||
|
compose.ComposeCancelSend.as_view(),
|
||||||
|
name="compose_cancel_send",
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
"compose/command-result/",
|
||||||
|
compose.ComposeCommandResult.as_view(),
|
||||||
|
name="compose_command_result",
|
||||||
|
),
|
||||||
path(
|
path(
|
||||||
"compose/drafts/",
|
"compose/drafts/",
|
||||||
compose.ComposeDrafts.as_view(),
|
compose.ComposeDrafts.as_view(),
|
||||||
|
|||||||
@@ -14,4 +14,3 @@ from django.core.wsgi import get_wsgi_application
|
|||||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "app.settings")
|
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "app.settings")
|
||||||
|
|
||||||
application = get_wsgi_application()
|
application = get_wsgi_application()
|
||||||
|
|
||||||
|
|||||||
@@ -7,8 +7,8 @@ from django.conf import settings
|
|||||||
|
|
||||||
from core.clients import ClientBase, transport
|
from core.clients import ClientBase, transport
|
||||||
from core.messaging import history
|
from core.messaging import history
|
||||||
from core.modules.mixed_protocol import normalize_gateway_event
|
|
||||||
from core.models import PersonIdentifier
|
from core.models import PersonIdentifier
|
||||||
|
from core.modules.mixed_protocol import normalize_gateway_event
|
||||||
|
|
||||||
|
|
||||||
class GatewayClient(ClientBase):
|
class GatewayClient(ClientBase):
|
||||||
@@ -48,7 +48,9 @@ class GatewayClient(ClientBase):
|
|||||||
self.log.info("%s gateway disabled by settings", self.service)
|
self.log.info("%s gateway disabled by settings", self.service)
|
||||||
return
|
return
|
||||||
if self._task is None:
|
if self._task is None:
|
||||||
self.log.info("%s gateway client starting (%s)", self.service, self.base_url)
|
self.log.info(
|
||||||
|
"%s gateway client starting (%s)", self.service, self.base_url
|
||||||
|
)
|
||||||
self._task = self.loop.create_task(self._poll_loop())
|
self._task = self.loop.create_task(self._poll_loop())
|
||||||
|
|
||||||
async def start_typing(self, identifier):
|
async def start_typing(self, identifier):
|
||||||
|
|||||||
@@ -5,4 +5,3 @@ Prefer importing from `core.clients.transport`.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
from core.clients.transport import * # noqa: F401,F403
|
from core.clients.transport import * # noqa: F401,F403
|
||||||
|
|
||||||
|
|||||||
@@ -456,7 +456,9 @@ class HandleMessage(Command):
|
|||||||
if session_key in session_cache:
|
if session_key in session_cache:
|
||||||
chat_session = session_cache[session_key]
|
chat_session = session_cache[session_key]
|
||||||
else:
|
else:
|
||||||
chat_session = await history.get_chat_session(identifier.user, identifier)
|
chat_session = await history.get_chat_session(
|
||||||
|
identifier.user, identifier
|
||||||
|
)
|
||||||
session_cache[session_key] = chat_session
|
session_cache[session_key] = chat_session
|
||||||
sender_key = source_uuid or source_number or identifier_candidates[0]
|
sender_key = source_uuid or source_number or identifier_candidates[0]
|
||||||
message_key = (chat_session.id, ts, sender_key)
|
message_key = (chat_session.id, ts, sender_key)
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ import base64
|
|||||||
import io
|
import io
|
||||||
import secrets
|
import secrets
|
||||||
import time
|
import time
|
||||||
from urllib.parse import quote_plus
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
from urllib.parse import quote_plus
|
||||||
|
|
||||||
import aiohttp
|
import aiohttp
|
||||||
import orjson
|
import orjson
|
||||||
@@ -40,6 +40,10 @@ def _runtime_command_result_key(service: str, command_id: str) -> str:
|
|||||||
return f"gia:service:command-result:{_service_key(service)}:{command_id}"
|
return f"gia:service:command-result:{_service_key(service)}:{command_id}"
|
||||||
|
|
||||||
|
|
||||||
|
def _runtime_command_cancel_key(service: str, command_id: str) -> str:
|
||||||
|
return f"gia:service:command-cancel:{_service_key(service)}:{command_id}"
|
||||||
|
|
||||||
|
|
||||||
def _gateway_base(service: str) -> str:
|
def _gateway_base(service: str) -> str:
|
||||||
key = f"{service.upper()}_HTTP_URL"
|
key = f"{service.upper()}_HTTP_URL"
|
||||||
default = f"http://{service}:8080"
|
default = f"http://{service}:8080"
|
||||||
@@ -88,7 +92,9 @@ def update_runtime_state(service: str, **updates):
|
|||||||
return state
|
return state
|
||||||
|
|
||||||
|
|
||||||
def enqueue_runtime_command(service: str, action: str, payload: dict | None = None) -> str:
|
def enqueue_runtime_command(
|
||||||
|
service: str, action: str, payload: dict | None = None
|
||||||
|
) -> str:
|
||||||
service_key = _service_key(service)
|
service_key = _service_key(service)
|
||||||
command_id = secrets.token_hex(12)
|
command_id = secrets.token_hex(12)
|
||||||
command = {
|
command = {
|
||||||
@@ -118,7 +124,9 @@ def pop_runtime_command(service: str) -> dict[str, Any] | None:
|
|||||||
return command
|
return command
|
||||||
|
|
||||||
|
|
||||||
def set_runtime_command_result(service: str, command_id: str, result: dict | None = None):
|
def set_runtime_command_result(
|
||||||
|
service: str, command_id: str, result: dict | None = None
|
||||||
|
):
|
||||||
service_key = _service_key(service)
|
service_key = _service_key(service)
|
||||||
result_key = _runtime_command_result_key(service_key, command_id)
|
result_key = _runtime_command_result_key(service_key, command_id)
|
||||||
payload = dict(result or {})
|
payload = dict(result or {})
|
||||||
@@ -126,7 +134,36 @@ def set_runtime_command_result(service: str, command_id: str, result: dict | Non
|
|||||||
cache.set(result_key, payload, timeout=_RUNTIME_COMMAND_RESULT_TTL)
|
cache.set(result_key, payload, timeout=_RUNTIME_COMMAND_RESULT_TTL)
|
||||||
|
|
||||||
|
|
||||||
async def wait_runtime_command_result(service: str, command_id: str, timeout: float = 20.0):
|
def cancel_runtime_command(service: str, command_id: str):
|
||||||
|
"""Mark a runtime command as cancelled and set a result so waiters are released."""
|
||||||
|
service_key = _service_key(service)
|
||||||
|
result_key = _runtime_command_result_key(service_key, command_id)
|
||||||
|
cancel_key = _runtime_command_cancel_key(service_key, command_id)
|
||||||
|
payload = {"ok": False, "error": "cancelled", "completed_at": int(time.time())}
|
||||||
|
cache.set(result_key, payload, timeout=_RUNTIME_COMMAND_RESULT_TTL)
|
||||||
|
cache.set(cancel_key, True, timeout=60)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def cancel_runtime_commands_for_recipient(service: str, recipient: str) -> list[str]:
|
||||||
|
"""Cancel any queued runtime commands for the given recipient and return their ids."""
|
||||||
|
service_key = _service_key(service)
|
||||||
|
key = _runtime_commands_key(service_key)
|
||||||
|
queued = list(cache.get(key) or [])
|
||||||
|
cancelled = []
|
||||||
|
for cmd in list(queued):
|
||||||
|
payload = dict(cmd.get("payload") or {})
|
||||||
|
if str(payload.get("recipient") or "").strip() == str(recipient or "").strip():
|
||||||
|
cmd_id = str(cmd.get("id") or "").strip()
|
||||||
|
if cmd_id:
|
||||||
|
cancel_runtime_command(service_key, cmd_id)
|
||||||
|
cancelled.append(cmd_id)
|
||||||
|
return cancelled
|
||||||
|
|
||||||
|
|
||||||
|
async def wait_runtime_command_result(
|
||||||
|
service: str, command_id: str, timeout: float = 20.0
|
||||||
|
):
|
||||||
service_key = _service_key(service)
|
service_key = _service_key(service)
|
||||||
result_key = _runtime_command_result_key(service_key, command_id)
|
result_key = _runtime_command_result_key(service_key, command_id)
|
||||||
deadline = time.monotonic() + max(0.1, float(timeout or 0.0))
|
deadline = time.monotonic() + max(0.1, float(timeout or 0.0))
|
||||||
@@ -149,7 +186,9 @@ def list_accounts(service: str):
|
|||||||
if service_key == "signal":
|
if service_key == "signal":
|
||||||
import requests
|
import requests
|
||||||
|
|
||||||
base = str(getattr(settings, "SIGNAL_HTTP_URL", "http://signal:8080")).rstrip("/")
|
base = str(getattr(settings, "SIGNAL_HTTP_URL", "http://signal:8080")).rstrip(
|
||||||
|
"/"
|
||||||
|
)
|
||||||
try:
|
try:
|
||||||
response = requests.get(f"{base}/v1/accounts", timeout=20)
|
response = requests.get(f"{base}/v1/accounts", timeout=20)
|
||||||
if not response.ok:
|
if not response.ok:
|
||||||
@@ -199,7 +238,9 @@ def unlink_account(service: str, account: str) -> bool:
|
|||||||
if service_key == "signal":
|
if service_key == "signal":
|
||||||
import requests
|
import requests
|
||||||
|
|
||||||
base = str(getattr(settings, "SIGNAL_HTTP_URL", "http://signal:8080")).rstrip("/")
|
base = str(getattr(settings, "SIGNAL_HTTP_URL", "http://signal:8080")).rstrip(
|
||||||
|
"/"
|
||||||
|
)
|
||||||
target = quote_plus(account_value)
|
target = quote_plus(account_value)
|
||||||
for path in (f"/v1/accounts/{target}", f"/v1/account/{target}"):
|
for path in (f"/v1/accounts/{target}", f"/v1/account/{target}"):
|
||||||
try:
|
try:
|
||||||
@@ -242,7 +283,9 @@ def unlink_account(service: str, account: str) -> bool:
|
|||||||
connected=bool(accounts),
|
connected=bool(accounts),
|
||||||
pair_status=("connected" if accounts else ""),
|
pair_status=("connected" if accounts else ""),
|
||||||
pair_qr="",
|
pair_qr="",
|
||||||
warning=("" if accounts else "Account unlinked. Add account to link again."),
|
warning=(
|
||||||
|
"" if accounts else "Account unlinked. Add account to link again."
|
||||||
|
),
|
||||||
last_event="account_unlinked",
|
last_event="account_unlinked",
|
||||||
last_error="",
|
last_error="",
|
||||||
)
|
)
|
||||||
@@ -619,7 +662,9 @@ def get_link_qr(service: str, device_name: str):
|
|||||||
if service_key == "signal":
|
if service_key == "signal":
|
||||||
import requests
|
import requests
|
||||||
|
|
||||||
base = str(getattr(settings, "SIGNAL_HTTP_URL", "http://signal:8080")).rstrip("/")
|
base = str(getattr(settings, "SIGNAL_HTTP_URL", "http://signal:8080")).rstrip(
|
||||||
|
"/"
|
||||||
|
)
|
||||||
response = requests.get(
|
response = requests.get(
|
||||||
f"{base}/v1/qrcodelink",
|
f"{base}/v1/qrcodelink",
|
||||||
params={"device_name": device},
|
params={"device_name": device},
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
|
import logging
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import sqlite3
|
import sqlite3
|
||||||
@@ -8,6 +9,7 @@ from urllib.parse import quote_plus
|
|||||||
import aiohttp
|
import aiohttp
|
||||||
from asgiref.sync import sync_to_async
|
from asgiref.sync import sync_to_async
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
from django.core.cache import cache
|
||||||
|
|
||||||
from core.clients import ClientBase, transport
|
from core.clients import ClientBase, transport
|
||||||
from core.messaging import history, media_bridge
|
from core.messaging import history, media_bridge
|
||||||
@@ -45,12 +47,11 @@ class WhatsAppClient(ClientBase):
|
|||||||
str(getattr(settings, "WHATSAPP_ENABLED", "false")).lower()
|
str(getattr(settings, "WHATSAPP_ENABLED", "false")).lower()
|
||||||
in {"1", "true", "yes", "on"}
|
in {"1", "true", "yes", "on"}
|
||||||
)
|
)
|
||||||
self.client_name = str(
|
self.client_name = (
|
||||||
getattr(settings, "WHATSAPP_CLIENT_NAME", "gia_whatsapp")
|
str(getattr(settings, "WHATSAPP_CLIENT_NAME", "gia_whatsapp")).strip()
|
||||||
).strip() or "gia_whatsapp"
|
or "gia_whatsapp"
|
||||||
self.database_url = str(
|
)
|
||||||
getattr(settings, "WHATSAPP_DATABASE_URL", "")
|
self.database_url = str(getattr(settings, "WHATSAPP_DATABASE_URL", "")).strip()
|
||||||
).strip()
|
|
||||||
safe_name = re.sub(r"[^a-zA-Z0-9_.-]+", "_", self.client_name) or "gia_whatsapp"
|
safe_name = re.sub(r"[^a-zA-Z0-9_.-]+", "_", self.client_name) or "gia_whatsapp"
|
||||||
# Use a persistent default path (under project mount) instead of /tmp so
|
# Use a persistent default path (under project mount) instead of /tmp so
|
||||||
# link state and contact cache survive container restarts.
|
# link state and contact cache survive container restarts.
|
||||||
@@ -70,14 +71,24 @@ class WhatsAppClient(ClientBase):
|
|||||||
self._publish_state(
|
self._publish_state(
|
||||||
connected=False,
|
connected=False,
|
||||||
warning=(
|
warning=(
|
||||||
"WhatsApp runtime is disabled by settings."
|
"WhatsApp runtime is disabled by settings." if not self.enabled else ""
|
||||||
if not self.enabled
|
|
||||||
else ""
|
|
||||||
),
|
),
|
||||||
accounts=prior_accounts,
|
accounts=prior_accounts,
|
||||||
last_event="init",
|
last_event="init",
|
||||||
session_db=self.session_db,
|
session_db=self.session_db,
|
||||||
)
|
)
|
||||||
|
# Reduce third-party library logging noise (neonize/grpc/protobuf).
|
||||||
|
try:
|
||||||
|
# Common noisy libraries used by Neonize/WhatsApp stacks
|
||||||
|
logging.getLogger("neonize").setLevel(logging.WARNING)
|
||||||
|
logging.getLogger("grpc").setLevel(logging.WARNING)
|
||||||
|
logging.getLogger("google").setLevel(logging.WARNING)
|
||||||
|
logging.getLogger("protobuf").setLevel(logging.WARNING)
|
||||||
|
logging.getLogger("whatsmeow").setLevel(logging.WARNING)
|
||||||
|
logging.getLogger("whatsmeow.Client").setLevel(logging.WARNING)
|
||||||
|
logging.getLogger("aiohttp.access").setLevel(logging.WARNING)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
def _publish_state(self, **updates):
|
def _publish_state(self, **updates):
|
||||||
state = transport.update_runtime_state(self.service, **updates)
|
state = transport.update_runtime_state(self.service, **updates)
|
||||||
@@ -96,8 +107,9 @@ class WhatsAppClient(ClientBase):
|
|||||||
async def _run(self):
|
async def _run(self):
|
||||||
try:
|
try:
|
||||||
import neonize.aioze.client as wa_client_mod
|
import neonize.aioze.client as wa_client_mod
|
||||||
from neonize.aioze.client import NewAClient
|
|
||||||
from neonize.aioze import events as wa_events
|
from neonize.aioze import events as wa_events
|
||||||
|
from neonize.aioze.client import NewAClient
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from neonize.utils.enum import ChatPresence, ChatPresenceMedia
|
from neonize.utils.enum import ChatPresence, ChatPresenceMedia
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -432,7 +444,9 @@ class WhatsAppClient(ClientBase):
|
|||||||
try:
|
try:
|
||||||
return cls(self.session_db)
|
return cls(self.session_db)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
self.log.warning("whatsapp client init failed (%s): %s", self.session_db, exc)
|
self.log.warning(
|
||||||
|
"whatsapp client init failed (%s): %s", self.session_db, exc
|
||||||
|
)
|
||||||
self._publish_state(
|
self._publish_state(
|
||||||
last_event="client_init_exception",
|
last_event="client_init_exception",
|
||||||
last_error=str(exc),
|
last_error=str(exc),
|
||||||
@@ -611,7 +625,9 @@ class WhatsAppClient(ClientBase):
|
|||||||
|
|
||||||
if offline_sync_completed_ev is not None:
|
if offline_sync_completed_ev is not None:
|
||||||
|
|
||||||
async def on_offline_sync_completed(client, event: offline_sync_completed_ev):
|
async def on_offline_sync_completed(
|
||||||
|
client, event: offline_sync_completed_ev
|
||||||
|
):
|
||||||
self._publish_state(last_event="offline_sync_completed")
|
self._publish_state(last_event="offline_sync_completed")
|
||||||
await self._sync_contacts_from_client()
|
await self._sync_contacts_from_client()
|
||||||
|
|
||||||
@@ -633,8 +649,7 @@ class WhatsAppClient(ClientBase):
|
|||||||
self._publish_state(
|
self._publish_state(
|
||||||
last_event="pair_waiting_no_qr",
|
last_event="pair_waiting_no_qr",
|
||||||
warning=(
|
warning=(
|
||||||
"Waiting for WhatsApp QR from Neonize. "
|
"Waiting for WhatsApp QR from Neonize. " "No QR callback received yet."
|
||||||
"No QR callback received yet."
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -663,10 +678,12 @@ class WhatsAppClient(ClientBase):
|
|||||||
text = payload.get("text")
|
text = payload.get("text")
|
||||||
attachments = payload.get("attachments") or []
|
attachments = payload.get("attachments") or []
|
||||||
try:
|
try:
|
||||||
|
# Include command_id so send_message_raw can observe cancel requests
|
||||||
result = await self.send_message_raw(
|
result = await self.send_message_raw(
|
||||||
recipient=recipient,
|
recipient=recipient,
|
||||||
text=text,
|
text=text,
|
||||||
attachments=attachments,
|
attachments=attachments,
|
||||||
|
command_id=command_id,
|
||||||
)
|
)
|
||||||
if result is not False and result is not None:
|
if result is not False and result is not None:
|
||||||
transport.set_runtime_command_result(
|
transport.set_runtime_command_result(
|
||||||
@@ -778,10 +795,14 @@ class WhatsAppClient(ClientBase):
|
|||||||
state = transport.get_runtime_state(self.service)
|
state = transport.get_runtime_state(self.service)
|
||||||
return {
|
return {
|
||||||
"started_at": started_at,
|
"started_at": started_at,
|
||||||
"finished_at": int(state.get("history_sync_finished_at") or int(time.time())),
|
"finished_at": int(
|
||||||
|
state.get("history_sync_finished_at") or int(time.time())
|
||||||
|
),
|
||||||
"duration_ms": int(state.get("history_sync_duration_ms") or 0),
|
"duration_ms": int(state.get("history_sync_duration_ms") or 0),
|
||||||
"contacts_sync_count": int(state.get("contacts_sync_count") or 0),
|
"contacts_sync_count": int(state.get("contacts_sync_count") or 0),
|
||||||
"history_imported_messages": int(state.get("history_imported_messages") or 0),
|
"history_imported_messages": int(
|
||||||
|
state.get("history_imported_messages") or 0
|
||||||
|
),
|
||||||
"sqlite_imported_messages": int(state.get("history_sqlite_imported") or 0),
|
"sqlite_imported_messages": int(state.get("history_sqlite_imported") or 0),
|
||||||
"sqlite_scanned_messages": int(state.get("history_sqlite_scanned") or 0),
|
"sqlite_scanned_messages": int(state.get("history_sqlite_scanned") or 0),
|
||||||
"sqlite_table": str(state.get("history_sqlite_table") or ""),
|
"sqlite_table": str(state.get("history_sqlite_table") or ""),
|
||||||
@@ -838,7 +859,9 @@ class WhatsAppClient(ClientBase):
|
|||||||
ID=str(anchor.get("msg_id") or ""),
|
ID=str(anchor.get("msg_id") or ""),
|
||||||
Timestamp=int(anchor.get("ts") or int(time.time() * 1000)),
|
Timestamp=int(anchor.get("ts") or int(time.time() * 1000)),
|
||||||
)
|
)
|
||||||
request_msg = build_history_sync_request(info, max(10, min(int(count or 120), 500)))
|
request_msg = build_history_sync_request(
|
||||||
|
info, max(10, min(int(count or 120), 500))
|
||||||
|
)
|
||||||
await self._maybe_await(self._client.send_message(chat_jid, request_msg))
|
await self._maybe_await(self._client.send_message(chat_jid, request_msg))
|
||||||
self._publish_state(
|
self._publish_state(
|
||||||
last_event="history_on_demand_requested",
|
last_event="history_on_demand_requested",
|
||||||
@@ -966,11 +989,17 @@ class WhatsAppClient(ClientBase):
|
|||||||
selected = name
|
selected = name
|
||||||
break
|
break
|
||||||
if not selected:
|
if not selected:
|
||||||
return {"rows": [], "table": "", "error": "messages_table_not_found"}
|
return {
|
||||||
|
"rows": [],
|
||||||
|
"table": "",
|
||||||
|
"error": "messages_table_not_found",
|
||||||
|
}
|
||||||
|
|
||||||
columns = [
|
columns = [
|
||||||
str(row[1] or "")
|
str(row[1] or "")
|
||||||
for row in cur.execute(f'PRAGMA table_info("{selected}")').fetchall()
|
for row in cur.execute(
|
||||||
|
f'PRAGMA table_info("{selected}")'
|
||||||
|
).fetchall()
|
||||||
]
|
]
|
||||||
if not columns:
|
if not columns:
|
||||||
return {"rows": [], "table": selected, "error": "no_columns"}
|
return {"rows": [], "table": selected, "error": "no_columns"}
|
||||||
@@ -1039,7 +1068,11 @@ class WhatsAppClient(ClientBase):
|
|||||||
"table": selected,
|
"table": selected,
|
||||||
"error": "required_message_columns_missing",
|
"error": "required_message_columns_missing",
|
||||||
}
|
}
|
||||||
select_cols = [col for col in [text_col, ts_col, from_me_col, sender_col, chat_col] if col]
|
select_cols = [
|
||||||
|
col
|
||||||
|
for col in [text_col, ts_col, from_me_col, sender_col, chat_col]
|
||||||
|
if col
|
||||||
|
]
|
||||||
quoted = ", ".join(f'"{col}"' for col in select_cols)
|
quoted = ", ".join(f'"{col}"' for col in select_cols)
|
||||||
order_expr = f'"{ts_col}" DESC' if ts_col else "ROWID DESC"
|
order_expr = f'"{ts_col}" DESC' if ts_col else "ROWID DESC"
|
||||||
sql = f'SELECT {quoted} FROM "{selected}" ORDER BY {order_expr} LIMIT 12000'
|
sql = f'SELECT {quoted} FROM "{selected}" ORDER BY {order_expr} LIMIT 12000'
|
||||||
@@ -1057,8 +1090,12 @@ class WhatsAppClient(ClientBase):
|
|||||||
text = str(row_map.get(text_col) or "").strip()
|
text = str(row_map.get(text_col) or "").strip()
|
||||||
if not text:
|
if not text:
|
||||||
continue
|
continue
|
||||||
raw_sender = str(row_map.get(sender_col) or "").strip() if sender_col else ""
|
raw_sender = (
|
||||||
raw_chat = str(row_map.get(chat_col) or "").strip() if chat_col else ""
|
str(row_map.get(sender_col) or "").strip() if sender_col else ""
|
||||||
|
)
|
||||||
|
raw_chat = (
|
||||||
|
str(row_map.get(chat_col) or "").strip() if chat_col else ""
|
||||||
|
)
|
||||||
raw_from_me = row_map.get(from_me_col) if from_me_col else None
|
raw_from_me = row_map.get(from_me_col) if from_me_col else None
|
||||||
parsed.append(
|
parsed.append(
|
||||||
{
|
{
|
||||||
@@ -1089,7 +1126,9 @@ class WhatsAppClient(ClientBase):
|
|||||||
target_candidates = self._normalize_identifier_candidates(identifier)
|
target_candidates = self._normalize_identifier_candidates(identifier)
|
||||||
target_local = str(identifier or "").strip().split("@", 1)[0]
|
target_local = str(identifier or "").strip().split("@", 1)[0]
|
||||||
if target_local:
|
if target_local:
|
||||||
target_candidates.update(self._normalize_identifier_candidates(target_local))
|
target_candidates.update(
|
||||||
|
self._normalize_identifier_candidates(target_local)
|
||||||
|
)
|
||||||
|
|
||||||
identifiers = await sync_to_async(list)(
|
identifiers = await sync_to_async(list)(
|
||||||
PersonIdentifier.objects.filter(service="whatsapp")
|
PersonIdentifier.objects.filter(service="whatsapp")
|
||||||
@@ -1348,7 +1387,9 @@ class WhatsAppClient(ClientBase):
|
|||||||
for row in cur.execute(
|
for row in cur.execute(
|
||||||
'SELECT DISTINCT "our_jid" FROM "whatsmeow_contacts"'
|
'SELECT DISTINCT "our_jid" FROM "whatsmeow_contacts"'
|
||||||
).fetchall():
|
).fetchall():
|
||||||
base = str((row or [None])[0] or "").strip().split("@", 1)[0]
|
base = (
|
||||||
|
str((row or [None])[0] or "").strip().split("@", 1)[0]
|
||||||
|
)
|
||||||
base = base.split(":", 1)[0]
|
base = base.split(":", 1)[0]
|
||||||
if base:
|
if base:
|
||||||
own_ids.add(base.lower())
|
own_ids.add(base.lower())
|
||||||
@@ -1364,9 +1405,18 @@ class WhatsAppClient(ClientBase):
|
|||||||
).fetchall()
|
).fetchall()
|
||||||
except Exception:
|
except Exception:
|
||||||
rows = []
|
rows = []
|
||||||
for their_jid, first_name, full_name, push_name, business_name in rows:
|
for (
|
||||||
|
their_jid,
|
||||||
|
first_name,
|
||||||
|
full_name,
|
||||||
|
push_name,
|
||||||
|
business_name,
|
||||||
|
) in rows:
|
||||||
jid_value = str(their_jid or "").strip()
|
jid_value = str(their_jid or "").strip()
|
||||||
if "@s.whatsapp.net" not in jid_value and "@lid" not in jid_value:
|
if (
|
||||||
|
"@s.whatsapp.net" not in jid_value
|
||||||
|
and "@lid" not in jid_value
|
||||||
|
):
|
||||||
continue
|
continue
|
||||||
identifier = jid_value.split("@", 1)[0].strip().split(":", 1)[0]
|
identifier = jid_value.split("@", 1)[0].strip().split(":", 1)[0]
|
||||||
if "@lid" in jid_value:
|
if "@lid" in jid_value:
|
||||||
@@ -1539,7 +1589,9 @@ class WhatsAppClient(ClientBase):
|
|||||||
me = await self._maybe_await(self._client.get_me())
|
me = await self._maybe_await(self._client.get_me())
|
||||||
if me:
|
if me:
|
||||||
self._connected = True
|
self._connected = True
|
||||||
self._publish_state(connected=True, warning="", pair_status="connected")
|
self._publish_state(
|
||||||
|
connected=True, warning="", pair_status="connected"
|
||||||
|
)
|
||||||
return True
|
return True
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
@@ -1561,22 +1613,17 @@ class WhatsAppClient(ClientBase):
|
|||||||
return
|
return
|
||||||
|
|
||||||
pushname_rows = (
|
pushname_rows = (
|
||||||
self._pluck(data, "pushnames")
|
self._pluck(data, "pushnames") or self._pluck(data, "Pushnames") or []
|
||||||
or self._pluck(data, "Pushnames")
|
|
||||||
or []
|
|
||||||
)
|
)
|
||||||
pushname_map = {}
|
pushname_map = {}
|
||||||
for row in pushname_rows:
|
for row in pushname_rows:
|
||||||
raw_id = (
|
raw_id = self._jid_to_identifier(
|
||||||
self._jid_to_identifier(self._pluck(row, "ID"))
|
self._pluck(row, "ID")
|
||||||
or self._jid_to_identifier(self._pluck(row, "id"))
|
) or self._jid_to_identifier(self._pluck(row, "id"))
|
||||||
)
|
|
||||||
if not raw_id:
|
if not raw_id:
|
||||||
continue
|
continue
|
||||||
pushname = str(
|
pushname = str(
|
||||||
self._pluck(row, "pushname")
|
self._pluck(row, "pushname") or self._pluck(row, "Pushname") or ""
|
||||||
or self._pluck(row, "Pushname")
|
|
||||||
or ""
|
|
||||||
).strip()
|
).strip()
|
||||||
if not pushname:
|
if not pushname:
|
||||||
continue
|
continue
|
||||||
@@ -1693,12 +1740,20 @@ class WhatsAppClient(ClientBase):
|
|||||||
self._pluck(msg_obj, "videoMessage", "caption"),
|
self._pluck(msg_obj, "videoMessage", "caption"),
|
||||||
self._pluck(msg_obj, "documentMessage", "caption"),
|
self._pluck(msg_obj, "documentMessage", "caption"),
|
||||||
self._pluck(msg_obj, "ephemeralMessage", "message", "conversation"),
|
self._pluck(msg_obj, "ephemeralMessage", "message", "conversation"),
|
||||||
self._pluck(msg_obj, "ephemeralMessage", "message", "extendedTextMessage", "text"),
|
self._pluck(
|
||||||
|
msg_obj, "ephemeralMessage", "message", "extendedTextMessage", "text"
|
||||||
|
),
|
||||||
self._pluck(msg_obj, "viewOnceMessage", "message", "conversation"),
|
self._pluck(msg_obj, "viewOnceMessage", "message", "conversation"),
|
||||||
self._pluck(msg_obj, "viewOnceMessage", "message", "extendedTextMessage", "text"),
|
self._pluck(
|
||||||
|
msg_obj, "viewOnceMessage", "message", "extendedTextMessage", "text"
|
||||||
|
),
|
||||||
self._pluck(msg_obj, "viewOnceMessageV2", "message", "conversation"),
|
self._pluck(msg_obj, "viewOnceMessageV2", "message", "conversation"),
|
||||||
self._pluck(msg_obj, "viewOnceMessageV2", "message", "extendedTextMessage", "text"),
|
self._pluck(
|
||||||
self._pluck(msg_obj, "viewOnceMessageV2Extension", "message", "conversation"),
|
msg_obj, "viewOnceMessageV2", "message", "extendedTextMessage", "text"
|
||||||
|
),
|
||||||
|
self._pluck(
|
||||||
|
msg_obj, "viewOnceMessageV2Extension", "message", "conversation"
|
||||||
|
),
|
||||||
self._pluck(
|
self._pluck(
|
||||||
msg_obj,
|
msg_obj,
|
||||||
"viewOnceMessageV2Extension",
|
"viewOnceMessageV2Extension",
|
||||||
@@ -1753,7 +1808,9 @@ class WhatsAppClient(ClientBase):
|
|||||||
)
|
)
|
||||||
return str(sender or fallback_chat_jid or "").strip()
|
return str(sender or fallback_chat_jid or "").strip()
|
||||||
|
|
||||||
async def _import_history_messages_for_conversation(self, row, chat_jid: str) -> int:
|
async def _import_history_messages_for_conversation(
|
||||||
|
self, row, chat_jid: str
|
||||||
|
) -> int:
|
||||||
imported = 0
|
imported = 0
|
||||||
msg_rows = self._history_message_rows(row)
|
msg_rows = self._history_message_rows(row)
|
||||||
if not msg_rows:
|
if not msg_rows:
|
||||||
@@ -1761,7 +1818,9 @@ class WhatsAppClient(ClientBase):
|
|||||||
chat_identifier = str(chat_jid or "").split("@", 1)[0].strip()
|
chat_identifier = str(chat_jid or "").split("@", 1)[0].strip()
|
||||||
if not chat_identifier:
|
if not chat_identifier:
|
||||||
return imported
|
return imported
|
||||||
candidate_values = self._normalize_identifier_candidates(chat_jid, chat_identifier)
|
candidate_values = self._normalize_identifier_candidates(
|
||||||
|
chat_jid, chat_identifier
|
||||||
|
)
|
||||||
if not candidate_values:
|
if not candidate_values:
|
||||||
return imported
|
return imported
|
||||||
identifiers = await sync_to_async(list)(
|
identifiers = await sync_to_async(list)(
|
||||||
@@ -1968,8 +2027,7 @@ class WhatsAppClient(ClientBase):
|
|||||||
or self._pluck(event, "info", "messageSource")
|
or self._pluck(event, "info", "messageSource")
|
||||||
)
|
)
|
||||||
is_from_me = bool(
|
is_from_me = bool(
|
||||||
self._pluck(source, "IsFromMe")
|
self._pluck(source, "IsFromMe") or self._pluck(source, "isFromMe")
|
||||||
or self._pluck(source, "isFromMe")
|
|
||||||
)
|
)
|
||||||
|
|
||||||
sender = self._jid_to_identifier(
|
sender = self._jid_to_identifier(
|
||||||
@@ -1979,8 +2037,7 @@ class WhatsAppClient(ClientBase):
|
|||||||
or self._pluck(source, "senderAlt")
|
or self._pluck(source, "senderAlt")
|
||||||
)
|
)
|
||||||
chat = self._jid_to_identifier(
|
chat = self._jid_to_identifier(
|
||||||
self._pluck(source, "Chat")
|
self._pluck(source, "Chat") or self._pluck(source, "chat")
|
||||||
or self._pluck(source, "chat")
|
|
||||||
)
|
)
|
||||||
raw_ts = (
|
raw_ts = (
|
||||||
self._pluck(event, "Info", "Timestamp")
|
self._pluck(event, "Info", "Timestamp")
|
||||||
@@ -2092,14 +2149,15 @@ class WhatsAppClient(ClientBase):
|
|||||||
or self._pluck(event, "info", "messageSource")
|
or self._pluck(event, "info", "messageSource")
|
||||||
)
|
)
|
||||||
sender = self._jid_to_identifier(
|
sender = self._jid_to_identifier(
|
||||||
self._pluck(source, "Sender")
|
self._pluck(source, "Sender") or self._pluck(source, "sender")
|
||||||
or self._pluck(source, "sender")
|
|
||||||
)
|
)
|
||||||
chat = self._jid_to_identifier(
|
chat = self._jid_to_identifier(
|
||||||
self._pluck(source, "Chat") or self._pluck(source, "chat")
|
self._pluck(source, "Chat") or self._pluck(source, "chat")
|
||||||
)
|
)
|
||||||
timestamps = []
|
timestamps = []
|
||||||
raw_ids = self._pluck(event, "MessageIDs") or self._pluck(event, "message_ids") or []
|
raw_ids = (
|
||||||
|
self._pluck(event, "MessageIDs") or self._pluck(event, "message_ids") or []
|
||||||
|
)
|
||||||
if isinstance(raw_ids, (list, tuple, set)):
|
if isinstance(raw_ids, (list, tuple, set)):
|
||||||
for item in raw_ids:
|
for item in raw_ids:
|
||||||
try:
|
try:
|
||||||
@@ -2141,8 +2199,7 @@ class WhatsAppClient(ClientBase):
|
|||||||
or {}
|
or {}
|
||||||
)
|
)
|
||||||
sender = self._jid_to_identifier(
|
sender = self._jid_to_identifier(
|
||||||
self._pluck(source, "Sender")
|
self._pluck(source, "Sender") or self._pluck(source, "sender")
|
||||||
or self._pluck(source, "sender")
|
|
||||||
)
|
)
|
||||||
chat = self._jid_to_identifier(
|
chat = self._jid_to_identifier(
|
||||||
self._pluck(source, "Chat") or self._pluck(source, "chat")
|
self._pluck(source, "Chat") or self._pluck(source, "chat")
|
||||||
@@ -2161,19 +2218,26 @@ class WhatsAppClient(ClientBase):
|
|||||||
await self.ur.started_typing(
|
await self.ur.started_typing(
|
||||||
self.service,
|
self.service,
|
||||||
identifier=candidate,
|
identifier=candidate,
|
||||||
payload={"presence": state_text, "sender": str(sender), "chat": str(chat)},
|
payload={
|
||||||
|
"presence": state_text,
|
||||||
|
"sender": str(sender),
|
||||||
|
"chat": str(chat),
|
||||||
|
},
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
await self.ur.stopped_typing(
|
await self.ur.stopped_typing(
|
||||||
self.service,
|
self.service,
|
||||||
identifier=candidate,
|
identifier=candidate,
|
||||||
payload={"presence": state_text, "sender": str(sender), "chat": str(chat)},
|
payload={
|
||||||
|
"presence": state_text,
|
||||||
|
"sender": str(sender),
|
||||||
|
"chat": str(chat),
|
||||||
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
async def _handle_presence_event(self, event):
|
async def _handle_presence_event(self, event):
|
||||||
sender = self._jid_to_identifier(
|
sender = self._jid_to_identifier(
|
||||||
self._pluck(event, "From", "User")
|
self._pluck(event, "From", "User") or self._pluck(event, "from", "user")
|
||||||
or self._pluck(event, "from", "user")
|
|
||||||
)
|
)
|
||||||
is_unavailable = bool(
|
is_unavailable = bool(
|
||||||
self._pluck(event, "Unavailable") or self._pluck(event, "unavailable")
|
self._pluck(event, "Unavailable") or self._pluck(event, "unavailable")
|
||||||
@@ -2271,7 +2335,9 @@ class WhatsAppClient(ClientBase):
|
|||||||
}
|
}
|
||||||
return None
|
return None
|
||||||
|
|
||||||
async def send_message_raw(self, recipient, text=None, attachments=None):
|
async def send_message_raw(
|
||||||
|
self, recipient, text=None, attachments=None, command_id: str | None = None
|
||||||
|
):
|
||||||
if not self._client:
|
if not self._client:
|
||||||
return False
|
return False
|
||||||
if self._build_jid is None:
|
if self._build_jid is None:
|
||||||
@@ -2283,8 +2349,12 @@ class WhatsAppClient(ClientBase):
|
|||||||
try:
|
try:
|
||||||
jid = self._build_jid(jid_str)
|
jid = self._build_jid(jid_str)
|
||||||
# Verify it's a proper JID object with SerializeToString method
|
# Verify it's a proper JID object with SerializeToString method
|
||||||
if not hasattr(jid, 'SerializeToString'):
|
if not hasattr(jid, "SerializeToString"):
|
||||||
self.log.error("whatsapp build_jid returned non-JID object: type=%s repr=%s", type(jid).__name__, repr(jid)[:100])
|
self.log.error(
|
||||||
|
"whatsapp build_jid returned non-JID object: type=%s repr=%s",
|
||||||
|
type(jid).__name__,
|
||||||
|
repr(jid)[:100],
|
||||||
|
)
|
||||||
return False
|
return False
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
self.log.warning("whatsapp failed to build JID from %s: %s", jid_str, exc)
|
self.log.warning("whatsapp failed to build JID from %s: %s", jid_str, exc)
|
||||||
@@ -2313,7 +2383,9 @@ class WhatsAppClient(ClientBase):
|
|||||||
payload = await self._fetch_attachment_payload(attachment)
|
payload = await self._fetch_attachment_payload(attachment)
|
||||||
if not payload:
|
if not payload:
|
||||||
continue
|
continue
|
||||||
mime = str(payload.get("content_type") or "application/octet-stream").lower()
|
mime = str(
|
||||||
|
payload.get("content_type") or "application/octet-stream"
|
||||||
|
).lower()
|
||||||
data = payload.get("content") or b""
|
data = payload.get("content") or b""
|
||||||
filename = payload.get("filename") or "attachment.bin"
|
filename = payload.get("filename") or "attachment.bin"
|
||||||
|
|
||||||
@@ -2327,7 +2399,9 @@ class WhatsAppClient(ClientBase):
|
|||||||
self._client.send_video(jid, data, caption="")
|
self._client.send_video(jid, data, caption="")
|
||||||
)
|
)
|
||||||
elif mime.startswith("audio/") and hasattr(self._client, "send_audio"):
|
elif mime.startswith("audio/") and hasattr(self._client, "send_audio"):
|
||||||
response = await self._maybe_await(self._client.send_audio(jid, data))
|
response = await self._maybe_await(
|
||||||
|
self._client.send_audio(jid, data)
|
||||||
|
)
|
||||||
elif hasattr(self._client, "send_document"):
|
elif hasattr(self._client, "send_document"):
|
||||||
response = await self._maybe_await(
|
response = await self._maybe_await(
|
||||||
self._client.send_document(
|
self._client.send_document(
|
||||||
@@ -2351,21 +2425,46 @@ class WhatsAppClient(ClientBase):
|
|||||||
if text:
|
if text:
|
||||||
response = None
|
response = None
|
||||||
last_error = None
|
last_error = None
|
||||||
for attempt in range(3):
|
# Prepare cancel key (if caller provided command_id)
|
||||||
|
cancel_key = None
|
||||||
|
try:
|
||||||
|
if command_id:
|
||||||
|
cancel_key = transport._runtime_command_cancel_key(
|
||||||
|
self.service, str(command_id)
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
cancel_key = None
|
||||||
|
|
||||||
|
for attempt in range(5): # Increased from 3 to 5 attempts
|
||||||
|
# Check for a cancellation marker set by transport.cancel_runtime_command
|
||||||
|
try:
|
||||||
|
if cancel_key and cache.get(cancel_key):
|
||||||
|
self.log.info("whatsapp send cancelled via cancel marker")
|
||||||
|
return False
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
try:
|
try:
|
||||||
# Log what we're about to send for debugging
|
# Log what we're about to send for debugging
|
||||||
self.log.debug(f"send_message attempt {attempt+1}: jid={jid} text_type={type(text).__name__} text_len={len(text)}")
|
if getattr(settings, "WHATSAPP_DEBUG", False):
|
||||||
response = await self._maybe_await(self._client.send_message(jid, text))
|
self.log.debug(
|
||||||
|
f"send_message attempt {attempt+1}: jid={jid} text_type={type(text).__name__} text_len={len(text)}"
|
||||||
|
)
|
||||||
|
response = await self._maybe_await(
|
||||||
|
self._client.send_message(jid, text)
|
||||||
|
)
|
||||||
sent_any = True
|
sent_any = True
|
||||||
last_error = None
|
last_error = None
|
||||||
break
|
break
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
self.log.debug(f"send_message attempt {attempt+1} failed: {type(exc).__name__}: {exc}")
|
if getattr(settings, "WHATSAPP_DEBUG", False):
|
||||||
|
self.log.debug(
|
||||||
|
f"send_message attempt {attempt+1} failed: {type(exc).__name__}: {exc}"
|
||||||
|
)
|
||||||
last_error = exc
|
last_error = exc
|
||||||
|
|
||||||
error_text = str(last_error or "").lower()
|
error_text = str(last_error or "").lower()
|
||||||
is_transient = "usync query" in error_text or "timed out" in error_text
|
is_transient = "usync query" in error_text or "timed out" in error_text
|
||||||
if is_transient and attempt < 2:
|
if is_transient and attempt < 4: # Updated to match new attempt range
|
||||||
if hasattr(self._client, "connect"):
|
if hasattr(self._client, "connect"):
|
||||||
try:
|
try:
|
||||||
await self._maybe_await(self._client.connect())
|
await self._maybe_await(self._client.connect())
|
||||||
@@ -2381,7 +2480,21 @@ class WhatsAppClient(ClientBase):
|
|||||||
last_event="send_retry_reconnect_failed",
|
last_event="send_retry_reconnect_failed",
|
||||||
last_error=str(reconnect_exc),
|
last_error=str(reconnect_exc),
|
||||||
)
|
)
|
||||||
await asyncio.sleep(0.8 * (attempt + 1))
|
# Sleep but wake earlier if cancelled: poll small intervals
|
||||||
|
# Increase backoff time for device list queries
|
||||||
|
total_sleep = 1.5 * (attempt + 1)
|
||||||
|
slept = 0.0
|
||||||
|
while slept < total_sleep:
|
||||||
|
try:
|
||||||
|
if cancel_key and cache.get(cancel_key):
|
||||||
|
self.log.info(
|
||||||
|
"whatsapp send cancelled during retry backoff"
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
await asyncio.sleep(0.2)
|
||||||
|
slept += 0.2
|
||||||
continue
|
continue
|
||||||
break
|
break
|
||||||
if last_error is not None and not sent_any:
|
if last_error is not None and not sent_any:
|
||||||
@@ -2420,7 +2533,9 @@ class WhatsAppClient(ClientBase):
|
|||||||
pass
|
pass
|
||||||
if hasattr(self._client, "set_chat_presence"):
|
if hasattr(self._client, "set_chat_presence"):
|
||||||
try:
|
try:
|
||||||
await self._maybe_await(self._client.set_chat_presence(jid, "composing"))
|
await self._maybe_await(
|
||||||
|
self._client.set_chat_presence(jid, "composing")
|
||||||
|
)
|
||||||
return True
|
return True
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|||||||
@@ -922,7 +922,9 @@ class XMPPComponent(ComponentXMPP):
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
if (not body or body.strip().lower() in {"[no body]", "(no text)"}) and attachments:
|
if (
|
||||||
|
not body or body.strip().lower() in {"[no body]", "(no text)"}
|
||||||
|
) and attachments:
|
||||||
attachment_urls = [
|
attachment_urls = [
|
||||||
str(item.get("url") or "").strip()
|
str(item.get("url") or "").strip()
|
||||||
for item in attachments
|
for item in attachments
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ from django.db.models import Q
|
|||||||
|
|
||||||
from core.models import Message, MessageEvent
|
from core.models import Message, MessageEvent
|
||||||
|
|
||||||
|
|
||||||
EMPTY_TEXT_VALUES = {
|
EMPTY_TEXT_VALUES = {
|
||||||
"",
|
"",
|
||||||
"[No Body]",
|
"[No Body]",
|
||||||
@@ -105,9 +104,7 @@ class Command(BaseCommand):
|
|||||||
|
|
||||||
queryset = Message.objects.filter(
|
queryset = Message.objects.filter(
|
||||||
sender_uuid__iexact="xmpp",
|
sender_uuid__iexact="xmpp",
|
||||||
).filter(
|
).filter(Q(text__isnull=True) | Q(text__exact="") | Q(text__iexact="[No Body]"))
|
||||||
Q(text__isnull=True) | Q(text__exact="") | Q(text__iexact="[No Body]")
|
|
||||||
)
|
|
||||||
if user_id:
|
if user_id:
|
||||||
queryset = queryset.filter(user_id=user_id)
|
queryset = queryset.filter(user_id=user_id)
|
||||||
queryset = queryset.order_by("ts", "id")
|
queryset = queryset.order_by("ts", "id")
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import time
|
|||||||
|
|
||||||
from django.core.cache import cache
|
from django.core.cache import cache
|
||||||
|
|
||||||
|
|
||||||
DEFAULT_BLOB_TTL_SECONDS = 60 * 20
|
DEFAULT_BLOB_TTL_SECONDS = 60 * 20
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -21,7 +21,9 @@ class UnifiedEvent:
|
|||||||
def normalize_gateway_event(service: str, payload: dict[str, Any]) -> UnifiedEvent:
|
def normalize_gateway_event(service: str, payload: dict[str, Any]) -> UnifiedEvent:
|
||||||
event_type = str(payload.get("type") or "").strip().lower()
|
event_type = str(payload.get("type") or "").strip().lower()
|
||||||
message_timestamps = []
|
message_timestamps = []
|
||||||
raw_timestamps = payload.get("message_timestamps") or payload.get("timestamps") or []
|
raw_timestamps = (
|
||||||
|
payload.get("message_timestamps") or payload.get("timestamps") or []
|
||||||
|
)
|
||||||
if isinstance(raw_timestamps, list):
|
if isinstance(raw_timestamps, list):
|
||||||
for item in raw_timestamps:
|
for item in raw_timestamps:
|
||||||
try:
|
try:
|
||||||
@@ -44,7 +46,10 @@ def normalize_gateway_event(service: str, payload: dict[str, Any]) -> UnifiedEve
|
|||||||
service=service,
|
service=service,
|
||||||
event_type=event_type,
|
event_type=event_type,
|
||||||
identifier=str(
|
identifier=str(
|
||||||
payload.get("identifier") or payload.get("source") or payload.get("from") or ""
|
payload.get("identifier")
|
||||||
|
or payload.get("source")
|
||||||
|
or payload.get("from")
|
||||||
|
or ""
|
||||||
).strip(),
|
).strip(),
|
||||||
text=str(payload.get("text") or ""),
|
text=str(payload.get("text") or ""),
|
||||||
ts=ts,
|
ts=ts,
|
||||||
|
|||||||
@@ -8,10 +8,7 @@ from django.core import signing
|
|||||||
|
|
||||||
from core.models import ChatSession, Message, PersonIdentifier, WorkspaceConversation
|
from core.models import ChatSession, Message, PersonIdentifier, WorkspaceConversation
|
||||||
from core.realtime.typing_state import get_person_typing_state
|
from core.realtime.typing_state import get_person_typing_state
|
||||||
from core.views.compose import (
|
from core.views.compose import COMPOSE_WS_TOKEN_SALT, _serialize_messages_with_artifacts
|
||||||
COMPOSE_WS_TOKEN_SALT,
|
|
||||||
_serialize_messages_with_artifacts,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _safe_int(value, default=0):
|
def _safe_int(value, default=0):
|
||||||
|
|||||||
@@ -25,9 +25,7 @@ def set_person_typing_state(
|
|||||||
"source_service": str(source_service or ""),
|
"source_service": str(source_service or ""),
|
||||||
"display_name": str(display_name or ""),
|
"display_name": str(display_name or ""),
|
||||||
"updated_ts": now_ms,
|
"updated_ts": now_ms,
|
||||||
"expires_ts": (
|
"expires_ts": (now_ms + (TYPING_TTL_SECONDS * 1000) if started else now_ms),
|
||||||
now_ms + (TYPING_TTL_SECONDS * 1000) if started else now_ms
|
|
||||||
),
|
|
||||||
}
|
}
|
||||||
cache.set(
|
cache.set(
|
||||||
_person_key(user_id, person_id),
|
_person_key(user_id, person_id),
|
||||||
|
|||||||
@@ -77,21 +77,21 @@
|
|||||||
headers: { 'HX-Request': 'true' },
|
headers: { 'HX-Request': 'true' },
|
||||||
})
|
})
|
||||||
.then((response) => {
|
.then((response) => {
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error('Failed contacts preview fetch.');
|
throw new Error('Failed contacts preview fetch.');
|
||||||
}
|
}
|
||||||
return response.text();
|
return response.text();
|
||||||
})
|
})
|
||||||
.then((html) => {
|
.then((html) => {
|
||||||
composeDropdown.innerHTML = html;
|
composeDropdown.innerHTML = html;
|
||||||
composePreviewLoaded = true;
|
composePreviewLoaded = true;
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
composePreviewLoaded = false;
|
composePreviewLoaded = false;
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
composePreviewLoading = false;
|
composePreviewLoading = false;
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -161,88 +161,88 @@
|
|||||||
|
|
||||||
<div class="table-container">
|
<div class="table-container">
|
||||||
<table id="discovered-contacts-table" class="table is-fullwidth is-hoverable is-striped">
|
<table id="discovered-contacts-table" class="table is-fullwidth is-hoverable is-striped">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th data-discovered-col="0" class="discovered-col-0">Person</th>
|
<th data-discovered-col="0" class="discovered-col-0">Person</th>
|
||||||
<th data-discovered-col="1" class="discovered-col-1">Name</th>
|
<th data-discovered-col="1" class="discovered-col-1">Name</th>
|
||||||
<th data-discovered-col="2" class="discovered-col-2">Service</th>
|
<th data-discovered-col="2" class="discovered-col-2">Service</th>
|
||||||
<th data-discovered-col="3" class="discovered-col-3">Identifier</th>
|
<th data-discovered-col="3" class="discovered-col-3">Identifier</th>
|
||||||
<th data-discovered-col="4" class="discovered-col-4">Suggest</th>
|
<th data-discovered-col="4" class="discovered-col-4">Suggest</th>
|
||||||
<th data-discovered-col="5" class="discovered-col-5">Status</th>
|
<th data-discovered-col="5" class="discovered-col-5">Status</th>
|
||||||
<th></th>
|
<th></th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{% for row in candidates %}
|
{% for row in candidates %}
|
||||||
<tr
|
<tr
|
||||||
data-service="{{ row.service }}"
|
data-service="{{ row.service }}"
|
||||||
data-person="{{ row.linked_person_name|default:'-'|lower }}"
|
data-person="{{ row.linked_person_name|default:'-'|lower }}"
|
||||||
data-detected="{{ row.detected_name|default:'-'|lower }}"
|
data-detected="{{ row.detected_name|default:'-'|lower }}"
|
||||||
data-identifier="{{ row.identifier|lower }}"
|
data-identifier="{{ row.identifier|lower }}"
|
||||||
data-search="{{ row.linked_person_name|default:'-'|lower }} {{ row.detected_name|default:'-'|lower }} {{ row.service|lower }} {{ row.identifier|lower }}">
|
data-search="{{ row.linked_person_name|default:'-'|lower }} {{ row.detected_name|default:'-'|lower }} {{ row.service|lower }} {{ row.identifier|lower }}">
|
||||||
<td data-discovered-col="0" class="discovered-col-0">{{ row.linked_person_name|default:"-" }}</td>
|
<td data-discovered-col="0" class="discovered-col-0">{{ row.linked_person_name|default:"-" }}</td>
|
||||||
<td data-discovered-col="1" class="discovered-col-1">{{ row.detected_name|default:"-" }}</td>
|
<td data-discovered-col="1" class="discovered-col-1">{{ row.detected_name|default:"-" }}</td>
|
||||||
<td data-discovered-col="2" class="discovered-col-2">
|
<td data-discovered-col="2" class="discovered-col-2">
|
||||||
{{ row.service|title }}
|
{{ row.service|title }}
|
||||||
</td>
|
</td>
|
||||||
<td data-discovered-col="3" class="discovered-col-3"><code>{{ row.identifier }}</code></td>
|
<td data-discovered-col="3" class="discovered-col-3"><code>{{ row.identifier }}</code></td>
|
||||||
<td data-discovered-col="4" class="discovered-col-4">
|
<td data-discovered-col="4" class="discovered-col-4">
|
||||||
{% if not row.linked_person and row.suggestions %}
|
{% if not row.linked_person and row.suggestions %}
|
||||||
<div class="buttons are-small">
|
<div class="buttons are-small">
|
||||||
{% for suggestion in row.suggestions %}
|
{% for suggestion in row.suggestions %}
|
||||||
<form method="post" style="display: inline-flex;">
|
<form method="post" style="display: inline-flex;">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
<input type="hidden" name="service" value="{{ row.service }}">
|
<input type="hidden" name="service" value="{{ row.service }}">
|
||||||
<input type="hidden" name="identifier" value="{{ row.identifier }}">
|
<input type="hidden" name="identifier" value="{{ row.identifier }}">
|
||||||
<input type="hidden" name="person_id" value="{{ suggestion.person.id }}">
|
<input type="hidden" name="person_id" value="{{ suggestion.person.id }}">
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
class="button is-small is-success is-light is-rounded"
|
class="button is-small is-success is-light is-rounded"
|
||||||
title="Accept suggested match">
|
title="Accept suggested match">
|
||||||
<span class="icon is-small"><i class="fa-solid fa-check"></i></span>
|
<span class="icon is-small"><i class="fa-solid fa-check"></i></span>
|
||||||
<span>{{ suggestion.person.name }}</span>
|
<span>{{ suggestion.person.name }}</span>
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
<span class="has-text-grey">-</span>
|
<span class="has-text-grey">-</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td data-discovered-col="5" class="discovered-col-5">
|
<td data-discovered-col="5" class="discovered-col-5">
|
||||||
{% if row.linked_person %}
|
{% if row.linked_person %}
|
||||||
<span class="tag is-success is-light">linked</span>
|
<span class="tag is-success is-light">linked</span>
|
||||||
{% else %}
|
{% else %}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="tag is-warning is-light js-unlinked-link"
|
||||||
|
data-service="{{ row.service }}"
|
||||||
|
data-identifier="{{ row.identifier }}"
|
||||||
|
title="Click to prefill link form">
|
||||||
|
unlinked
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="tag is-warning is-light js-unlinked-link"
|
class="button is-small is-light js-contact-info"
|
||||||
data-service="{{ row.service }}"
|
data-service="{{ row.service|title }}"
|
||||||
data-identifier="{{ row.identifier }}"
|
data-identifier="{{ row.identifier }}"
|
||||||
title="Click to prefill link form">
|
data-person="{{ row.linked_person_name|default:'-' }}"
|
||||||
unlinked
|
data-detected="{{ row.detected_name|default:'-' }}"
|
||||||
|
data-status="{% if row.linked_person %}linked{% else %}unlinked{% endif %}"
|
||||||
|
data-suggested="{% if row.suggestions %}{% for suggestion in row.suggestions %}{{ suggestion.person.name }}{% if not forloop.last %}, {% endif %}{% endfor %}{% else %}-{% endif %}">
|
||||||
|
<span class="icon is-small"><i class="fa-solid fa-circle-info"></i></span>
|
||||||
</button>
|
</button>
|
||||||
{% endif %}
|
<a class="button is-small is-light" href="{{ row.compose_url }}">
|
||||||
</td>
|
<span class="icon is-small"><i class="fa-solid fa-paper-plane"></i></span>
|
||||||
<td>
|
</a>
|
||||||
<button
|
</td>
|
||||||
type="button"
|
</tr>
|
||||||
class="button is-small is-light js-contact-info"
|
{% endfor %}
|
||||||
data-service="{{ row.service|title }}"
|
</tbody>
|
||||||
data-identifier="{{ row.identifier }}"
|
</table>
|
||||||
data-person="{{ row.linked_person_name|default:'-' }}"
|
|
||||||
data-detected="{{ row.detected_name|default:'-' }}"
|
|
||||||
data-status="{% if row.linked_person %}linked{% else %}unlinked{% endif %}"
|
|
||||||
data-suggested="{% if row.suggestions %}{% for suggestion in row.suggestions %}{{ suggestion.person.name }}{% if not forloop.last %}, {% endif %}{% endfor %}{% else %}-{% endif %}">
|
|
||||||
<span class="icon is-small"><i class="fa-solid fa-circle-info"></i></span>
|
|
||||||
</button>
|
|
||||||
<a class="button is-small is-light" href="{{ row.compose_url }}">
|
|
||||||
<span class="icon is-small"><i class="fa-solid fa-paper-plane"></i></span>
|
|
||||||
</a>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{% endfor %}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
|
|||||||
@@ -399,8 +399,8 @@
|
|||||||
return String(value || "")
|
return String(value || "")
|
||||||
.split(",")
|
.split(",")
|
||||||
.map(function (item) {
|
.map(function (item) {
|
||||||
return item.trim();
|
return item.trim();
|
||||||
})
|
})
|
||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -536,8 +536,8 @@
|
|||||||
showOperationPane(operation);
|
showOperationPane(operation);
|
||||||
const activeTab = tabKey || (
|
const activeTab = tabKey || (
|
||||||
operation === "artifacts"
|
operation === "artifacts"
|
||||||
? ((window.giaWorkspaceState[personId] || {}).currentMitigationTab || "plan_board")
|
? ((window.giaWorkspaceState[personId] || {}).currentMitigationTab || "plan_board")
|
||||||
: operation
|
: operation
|
||||||
);
|
);
|
||||||
setTopCapsuleActive(activeTab);
|
setTopCapsuleActive(activeTab);
|
||||||
const hydrated = hydrateCachedIfAvailable(operation);
|
const hydrated = hydrateCachedIfAvailable(operation);
|
||||||
@@ -573,8 +573,8 @@
|
|||||||
const currentState = window.giaWorkspaceState[personId] || {};
|
const currentState = window.giaWorkspaceState[personId] || {};
|
||||||
const targetTabKey = currentState.pendingTabKey || (
|
const targetTabKey = currentState.pendingTabKey || (
|
||||||
operation === "artifacts"
|
operation === "artifacts"
|
||||||
? (currentState.currentMitigationTab || "plan_board")
|
? (currentState.currentMitigationTab || "plan_board")
|
||||||
: operation
|
: operation
|
||||||
);
|
);
|
||||||
if (!forceRefresh && currentState.current === operation && pane.dataset.loaded === "1") {
|
if (!forceRefresh && currentState.current === operation && pane.dataset.loaded === "1") {
|
||||||
window.giaWorkspaceShowTab(personId, operation, targetTabKey);
|
window.giaWorkspaceShowTab(personId, operation, targetTabKey);
|
||||||
@@ -663,8 +663,8 @@
|
|||||||
const state = window.giaWorkspaceState[personId] || {};
|
const state = window.giaWorkspaceState[personId] || {};
|
||||||
const currentTab = state.currentTab || (
|
const currentTab = state.currentTab || (
|
||||||
state.current === "artifacts"
|
state.current === "artifacts"
|
||||||
? (state.currentMitigationTab || "plan_board")
|
? (state.currentMitigationTab || "plan_board")
|
||||||
: (state.current || "plan_board")
|
: (state.current || "plan_board")
|
||||||
);
|
);
|
||||||
window.giaWorkspaceOpenTab(personId, currentTab, true);
|
window.giaWorkspaceOpenTab(personId, currentTab, true);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -254,6 +254,9 @@
|
|||||||
{% endwith %}
|
{% endwith %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<article class="compose-bubble {% if msg.outgoing %}is-out{% else %}is-in{% endif %}">
|
<article class="compose-bubble {% if msg.outgoing %}is-out{% else %}is-in{% endif %}">
|
||||||
|
<div class="compose-source-badge-wrap">
|
||||||
|
<span class="compose-source-badge source-{{ msg.source_service|default:'web'|lower }}">{{ msg.source_label }}</span>
|
||||||
|
</div>
|
||||||
{% if msg.image_urls %}
|
{% if msg.image_urls %}
|
||||||
{% for image_url in msg.image_urls %}
|
{% for image_url in msg.image_urls %}
|
||||||
<figure class="compose-media">
|
<figure class="compose-media">
|
||||||
@@ -285,14 +288,30 @@
|
|||||||
<p class="compose-msg-meta">
|
<p class="compose-msg-meta">
|
||||||
{{ msg.display_ts }}{% if msg.author %} · {{ msg.author }}{% endif %}
|
{{ msg.display_ts }}{% if msg.author %} · {{ msg.author }}{% endif %}
|
||||||
{% if msg.read_ts %}
|
{% if msg.read_ts %}
|
||||||
<span class="compose-ticks" title="Read at {{ msg.read_display }}">
|
<span
|
||||||
|
class="compose-ticks js-receipt-trigger"
|
||||||
|
role="button"
|
||||||
|
tabindex="0"
|
||||||
|
data-receipt='{{ msg.receipt_payload|default:"{}"|escapejs }}'
|
||||||
|
data-source='{{ msg.read_source_service }}'
|
||||||
|
data-by='{{ msg.read_by_identifier }}'
|
||||||
|
data-id='{{ msg.id }}'
|
||||||
|
title="Read at {{ msg.read_display }}">
|
||||||
<span class="icon is-small"><i class="fa-solid fa-check-double has-text-info"></i></span>
|
<span class="icon is-small"><i class="fa-solid fa-check-double has-text-info"></i></span>
|
||||||
<span class="compose-tick-time">{{ msg.read_display }}</span>
|
<span class="compose-tick-time">{{ msg.read_delta_display }}</span>
|
||||||
</span>
|
</span>
|
||||||
{% elif msg.delivered_ts %}
|
{% elif msg.delivered_ts %}
|
||||||
<span class="compose-ticks" title="Delivered at {{ msg.delivered_display }}">
|
<span
|
||||||
|
class="compose-ticks js-receipt-trigger"
|
||||||
|
role="button"
|
||||||
|
tabindex="0"
|
||||||
|
data-receipt='{{ msg.receipt_payload|default:"{}"|escapejs }}'
|
||||||
|
data-source='{{ msg.read_source_service }}'
|
||||||
|
data-by='{{ msg.read_by_identifier }}'
|
||||||
|
data-id='{{ msg.id }}'
|
||||||
|
title="Delivered at {{ msg.delivered_display }}">
|
||||||
<span class="icon is-small"><i class="fa-solid fa-check-double has-text-grey"></i></span>
|
<span class="icon is-small"><i class="fa-solid fa-check-double has-text-grey"></i></span>
|
||||||
<span class="compose-tick-time">{{ msg.delivered_display }}</span>
|
<span class="compose-tick-time">{{ msg.delivered_delta_display }}</span>
|
||||||
</span>
|
</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</p>
|
</p>
|
||||||
@@ -438,6 +457,26 @@
|
|||||||
padding: 0.52rem 0.62rem;
|
padding: 0.52rem 0.62rem;
|
||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
}
|
}
|
||||||
|
#{{ panel_id }} .compose-source-badge-wrap {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-start;
|
||||||
|
margin-bottom: 0.36rem;
|
||||||
|
}
|
||||||
|
#{{ panel_id }} .compose-source-badge {
|
||||||
|
font-size: 0.84rem;
|
||||||
|
padding: 0.12rem 0.5rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
color: #fff;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
box-shadow: 0 1px 0 rgba(0,0,0,0.06);
|
||||||
|
}
|
||||||
|
#{{ panel_id }} .compose-source-badge.source-web { background: #2f4f7a; }
|
||||||
|
#{{ panel_id }} .compose-source-badge.source-xmpp { background: #6a88b4; }
|
||||||
|
#{{ panel_id }} .compose-source-badge.source-whatsapp { background: #25D366; color: #063; }
|
||||||
|
#{{ panel_id }} .compose-source-badge.source-signal { background: #3b82f6; }
|
||||||
|
#{{ panel_id }} .compose-source-badge.source-instagram { background: #c13584; }
|
||||||
|
#{{ panel_id }} .compose-source-badge.source-unknown { background: #6b7280; }
|
||||||
#{{ panel_id }} .compose-bubble.is-in {
|
#{{ panel_id }} .compose-bubble.is-in {
|
||||||
background: rgba(255, 255, 255, 0.96);
|
background: rgba(255, 255, 255, 0.96);
|
||||||
}
|
}
|
||||||
@@ -1719,6 +1758,7 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
const appendBubble = function (msg) {
|
const appendBubble = function (msg) {
|
||||||
|
console.log("[appendBubble]", {id: msg.id, ts: msg.ts, author: msg.author, source_label: msg.source_label, source_service: msg.source_service, outgoing: msg.outgoing});
|
||||||
const row = document.createElement("div");
|
const row = document.createElement("div");
|
||||||
const outgoing = !!msg.outgoing;
|
const outgoing = !!msg.outgoing;
|
||||||
row.className = "compose-row " + (outgoing ? "is-out" : "is-in");
|
row.className = "compose-row " + (outgoing ? "is-out" : "is-in");
|
||||||
@@ -1729,12 +1769,24 @@
|
|||||||
const bubble = document.createElement("article");
|
const bubble = document.createElement("article");
|
||||||
bubble.className = "compose-bubble " + (outgoing ? "is-out" : "is-in");
|
bubble.className = "compose-bubble " + (outgoing ? "is-out" : "is-in");
|
||||||
|
|
||||||
|
// Add source badge for client-side rendered messages
|
||||||
|
if (msg.source_label) {
|
||||||
|
console.log("[appendBubble] rendering source badge:", msg.source_label);
|
||||||
|
const badgeWrap = document.createElement("div");
|
||||||
|
badgeWrap.className = "compose-source-badge-wrap";
|
||||||
|
const badge = document.createElement("span");
|
||||||
|
const svc = String(msg.source_service || "web").toLowerCase();
|
||||||
|
badge.className = "compose-source-badge source-" + svc;
|
||||||
|
badge.textContent = String(msg.source_label || "");
|
||||||
|
badgeWrap.appendChild(badge);
|
||||||
|
bubble.appendChild(badgeWrap);
|
||||||
|
}
|
||||||
const imageCandidatesFromPayload = Array.isArray(msg.image_urls) && msg.image_urls.length
|
const imageCandidatesFromPayload = Array.isArray(msg.image_urls) && msg.image_urls.length
|
||||||
? msg.image_urls
|
? msg.image_urls
|
||||||
: (msg.image_url ? [msg.image_url] : []);
|
: (msg.image_url ? [msg.image_url] : []);
|
||||||
const imageCandidates = imageCandidatesFromPayload.length
|
const imageCandidates = imageCandidatesFromPayload.length
|
||||||
? imageCandidatesFromPayload
|
? imageCandidatesFromPayload
|
||||||
: extractUrlCandidates(msg.text || msg.display_text || "");
|
: extractUrlCandidates(msg.text || msg.display_text || "");
|
||||||
appendImageCandidates(bubble, imageCandidates);
|
appendImageCandidates(bubble, imageCandidates);
|
||||||
|
|
||||||
if (!msg.hide_text) {
|
if (!msg.hide_text) {
|
||||||
@@ -1759,44 +1811,55 @@
|
|||||||
if (msg.author) {
|
if (msg.author) {
|
||||||
metaText += " · " + String(msg.author);
|
metaText += " · " + String(msg.author);
|
||||||
}
|
}
|
||||||
meta.textContent = metaText;
|
meta.textContent = metaText;
|
||||||
// Render delivery/read ticks and a small time label when available.
|
// Render delivery/read ticks and a small time label when available.
|
||||||
if (msg.read_ts) {
|
if (msg.read_ts) {
|
||||||
const tickWrap = document.createElement("span");
|
const tickWrap = document.createElement("span");
|
||||||
tickWrap.className = "compose-ticks";
|
tickWrap.className = "compose-ticks";
|
||||||
tickWrap.title = "Read at " + String(msg.read_display || msg.read_ts || "");
|
tickWrap.title = "Read at " + String(msg.read_display || msg.read_ts || "");
|
||||||
const icon = document.createElement("span");
|
const icon = document.createElement("span");
|
||||||
icon.className = "icon is-small";
|
icon.className = "icon is-small";
|
||||||
const i = document.createElement("i");
|
const i = document.createElement("i");
|
||||||
i.className = "fa-solid fa-check-double has-text-info";
|
i.className = "fa-solid fa-check-double has-text-info";
|
||||||
icon.appendChild(i);
|
icon.appendChild(i);
|
||||||
const timeSpan = document.createElement("span");
|
const timeSpan = document.createElement("span");
|
||||||
timeSpan.className = "compose-tick-time";
|
timeSpan.className = "compose-tick-time";
|
||||||
timeSpan.textContent = String(msg.read_display || "");
|
timeSpan.textContent = String(msg.read_display || "");
|
||||||
tickWrap.appendChild(icon);
|
tickWrap.appendChild(icon);
|
||||||
tickWrap.appendChild(timeSpan);
|
tickWrap.appendChild(timeSpan);
|
||||||
meta.appendChild(document.createTextNode(" "));
|
meta.appendChild(document.createTextNode(" "));
|
||||||
meta.appendChild(tickWrap);
|
meta.appendChild(tickWrap);
|
||||||
} else if (msg.delivered_ts) {
|
} else if (msg.delivered_ts) {
|
||||||
const tickWrap = document.createElement("span");
|
const tickWrap = document.createElement("span");
|
||||||
tickWrap.className = "compose-ticks";
|
tickWrap.className = "compose-ticks";
|
||||||
tickWrap.title = "Delivered at " + String(msg.delivered_display || msg.delivered_ts || "");
|
tickWrap.title = "Delivered at " + String(msg.delivered_display || msg.delivered_ts || "");
|
||||||
const icon = document.createElement("span");
|
const icon = document.createElement("span");
|
||||||
icon.className = "icon is-small";
|
icon.className = "icon is-small";
|
||||||
const i = document.createElement("i");
|
const i = document.createElement("i");
|
||||||
i.className = "fa-solid fa-check-double has-text-grey";
|
i.className = "fa-solid fa-check-double has-text-grey";
|
||||||
icon.appendChild(i);
|
icon.appendChild(i);
|
||||||
const timeSpan = document.createElement("span");
|
const timeSpan = document.createElement("span");
|
||||||
timeSpan.className = "compose-tick-time";
|
timeSpan.className = "compose-tick-time";
|
||||||
timeSpan.textContent = String(msg.delivered_display || "");
|
timeSpan.textContent = String(msg.delivered_display || "");
|
||||||
tickWrap.appendChild(icon);
|
tickWrap.appendChild(icon);
|
||||||
tickWrap.appendChild(timeSpan);
|
tickWrap.appendChild(timeSpan);
|
||||||
meta.appendChild(document.createTextNode(" "));
|
meta.appendChild(document.createTextNode(" "));
|
||||||
meta.appendChild(tickWrap);
|
meta.appendChild(tickWrap);
|
||||||
}
|
}
|
||||||
bubble.appendChild(meta);
|
bubble.appendChild(meta);
|
||||||
|
|
||||||
row.appendChild(bubble);
|
// If message carries receipt metadata, append dataset so the popover can use it.
|
||||||
|
if (msg.receipt_payload || msg.read_source_service || msg.read_by_identifier) {
|
||||||
|
// Attach data attributes on the row so event delegation can find them.
|
||||||
|
try {
|
||||||
|
row.dataset.receipt = JSON.stringify(msg.receipt_payload || {});
|
||||||
|
} catch (e) {
|
||||||
|
row.dataset.receipt = "{}";
|
||||||
|
}
|
||||||
|
row.dataset.receiptSource = String(msg.read_source_service || "");
|
||||||
|
row.dataset.receiptBy = String(msg.read_by_identifier || "");
|
||||||
|
row.dataset.receiptId = String(msg.id || "");
|
||||||
|
}
|
||||||
const empty = thread.querySelector(".compose-empty");
|
const empty = thread.querySelector(".compose-empty");
|
||||||
if (empty) {
|
if (empty) {
|
||||||
empty.remove();
|
empty.remove();
|
||||||
@@ -1810,6 +1873,87 @@
|
|||||||
updateGlanceFromMessage(msg);
|
updateGlanceFromMessage(msg);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Receipt popover (similar to contact info popover)
|
||||||
|
const receiptPopover = document.createElement("div");
|
||||||
|
receiptPopover.id = "compose-receipt-popover";
|
||||||
|
receiptPopover.className = "compose-ai-popover is-hidden";
|
||||||
|
receiptPopover.setAttribute("aria-hidden", "true");
|
||||||
|
receiptPopover.innerHTML = `
|
||||||
|
<div class="compose-ai-card is-active" style="min-width:18rem;">
|
||||||
|
<p class="compose-ai-title">Receipt Details</p>
|
||||||
|
<div class="compose-ai-content">
|
||||||
|
<table class="table is-fullwidth is-striped is-size-7"><tbody>
|
||||||
|
<tr><th>Message ID</th><td id="receipt-msg-id">-</td></tr>
|
||||||
|
<tr><th>Source</th><td id="receipt-source">-</td></tr>
|
||||||
|
<tr><th>Read By</th><td id="receipt-by">-</td></tr>
|
||||||
|
<tr><th>Delivered</th><td id="receipt-delivered">-</td></tr>
|
||||||
|
<tr><th>Read</th><td id="receipt-read">-</td></tr>
|
||||||
|
<tr><th>Payload</th><td><pre id="receipt-payload" style="white-space:pre-wrap;max-height:18rem;overflow:auto"></pre></td></tr>
|
||||||
|
</tbody></table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
document.body.appendChild(receiptPopover);
|
||||||
|
|
||||||
|
let activeReceiptBtn = null;
|
||||||
|
function hideReceiptPopover() {
|
||||||
|
receiptPopover.classList.add("is-hidden");
|
||||||
|
receiptPopover.setAttribute("aria-hidden", "true");
|
||||||
|
activeReceiptBtn = null;
|
||||||
|
}
|
||||||
|
function positionReceiptPopover(btn) {
|
||||||
|
const rect = btn.getBoundingClientRect();
|
||||||
|
const width = Math.min(520, Math.max(280, Math.floor(window.innerWidth * 0.32)));
|
||||||
|
const left = Math.min(window.innerWidth - width - 16, Math.max(12, rect.left - width + rect.width));
|
||||||
|
const top = Math.min(window.innerHeight - 24, rect.bottom + 8);
|
||||||
|
receiptPopover.style.left = left + "px";
|
||||||
|
receiptPopover.style.top = top + "px";
|
||||||
|
receiptPopover.style.width = width + "px";
|
||||||
|
}
|
||||||
|
function openReceiptPopoverFromData(data, btn) {
|
||||||
|
document.getElementById("receipt-msg-id").textContent = data.id || "-";
|
||||||
|
document.getElementById("receipt-source").textContent = data.source || "-";
|
||||||
|
document.getElementById("receipt-by").textContent = data.by || "-";
|
||||||
|
document.getElementById("receipt-delivered").textContent = data.delivered || "-";
|
||||||
|
document.getElementById("receipt-read").textContent = data.read || "-";
|
||||||
|
try {
|
||||||
|
const out = typeof data.payload === 'string' ? JSON.parse(data.payload) : data.payload || {};
|
||||||
|
document.getElementById("receipt-payload").textContent = JSON.stringify(out, null, 2);
|
||||||
|
} catch (e) {
|
||||||
|
document.getElementById("receipt-payload").textContent = String(data.payload || "{}");
|
||||||
|
}
|
||||||
|
positionReceiptPopover(btn);
|
||||||
|
receiptPopover.classList.remove("is-hidden");
|
||||||
|
receiptPopover.setAttribute("aria-hidden", "false");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delegate click on tick triggers inside thread
|
||||||
|
thread.addEventListener("click", function (ev) {
|
||||||
|
const btn = ev.target.closest && ev.target.closest('.js-receipt-trigger');
|
||||||
|
if (!btn) return;
|
||||||
|
if (activeReceiptBtn === btn && !receiptPopover.classList.contains('is-hidden')) {
|
||||||
|
hideReceiptPopover();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
activeReceiptBtn = btn;
|
||||||
|
const payload = btn.dataset && btn.dataset.receipt ? btn.dataset.receipt : (btn.parentNode && btn.parentNode.dataset ? btn.parentNode.dataset.receipt : "{}");
|
||||||
|
const source = btn.dataset && btn.dataset.source ? btn.dataset.source : (btn.parentNode && btn.parentNode.dataset ? btn.parentNode.dataset.receiptSource : "");
|
||||||
|
const by = btn.dataset && btn.dataset.by ? btn.dataset.by : (btn.parentNode && btn.parentNode.dataset ? btn.parentNode.dataset.receiptBy : "");
|
||||||
|
const id = btn.dataset && btn.dataset.id ? btn.dataset.id : (btn.parentNode && btn.parentNode.dataset ? btn.parentNode.dataset.receiptId : "");
|
||||||
|
const delivered = btn.title || "";
|
||||||
|
const read = btn.title || "";
|
||||||
|
openReceiptPopoverFromData({ id: id, payload: payload, source: source, by: by, delivered: delivered, read: read }, btn);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Close receipt popover on outside click / escape
|
||||||
|
document.addEventListener("click", function (ev) {
|
||||||
|
if (receiptPopover.classList.contains('is-hidden')) return;
|
||||||
|
if (receiptPopover.contains(ev.target)) return;
|
||||||
|
if (activeReceiptBtn && activeReceiptBtn.contains(ev.target)) return;
|
||||||
|
hideReceiptPopover();
|
||||||
|
});
|
||||||
|
document.addEventListener("keydown", function (ev) { if (ev.key === 'Escape') hideReceiptPopover(); });
|
||||||
|
|
||||||
const applyMinuteGrouping = function () {
|
const applyMinuteGrouping = function () {
|
||||||
const rows = Array.from(thread.querySelectorAll(".compose-row"));
|
const rows = Array.from(thread.querySelectorAll(".compose-row"));
|
||||||
rows.forEach(function (row) {
|
rows.forEach(function (row) {
|
||||||
@@ -1889,15 +2033,18 @@
|
|||||||
}
|
}
|
||||||
params.set("limit", thread.dataset.limit || "60");
|
params.set("limit", thread.dataset.limit || "60");
|
||||||
params.set("after_ts", String(lastTs));
|
params.set("after_ts", String(lastTs));
|
||||||
|
console.log("[poll] fetching messages: service=" + params.get("service") + " after_ts=" + lastTs);
|
||||||
const response = await fetch(thread.dataset.pollUrl + "?" + params.toString(), {
|
const response = await fetch(thread.dataset.pollUrl + "?" + params.toString(), {
|
||||||
method: "GET",
|
method: "GET",
|
||||||
credentials: "same-origin",
|
credentials: "same-origin",
|
||||||
headers: { Accept: "application/json" }
|
headers: { Accept: "application/json" }
|
||||||
});
|
});
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
|
console.log("[poll] response not ok:", response.status);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const payload = await response.json();
|
const payload = await response.json();
|
||||||
|
console.log("[poll] received payload with " + (payload.messages ? payload.messages.length : 0) + " messages");
|
||||||
appendMessages(payload.messages || [], forceScroll);
|
appendMessages(payload.messages || [], forceScroll);
|
||||||
if (payload.typing) {
|
if (payload.typing) {
|
||||||
applyTyping(payload.typing);
|
applyTyping(payload.typing);
|
||||||
@@ -2522,7 +2669,7 @@
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
setCardLoading(card, false);
|
setCardLoading(card, false);
|
||||||
card.querySelector(".compose-ai-content").textContent =
|
card.querySelector(".compose-ai-content").textContent =
|
||||||
"Failed to load quick insights.";
|
"Failed to load quick insights.";
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -2541,8 +2688,8 @@
|
|||||||
const customText = card.querySelector(".engage-custom-text");
|
const customText = card.querySelector(".engage-custom-text");
|
||||||
const selectedSource = (
|
const selectedSource = (
|
||||||
preferredSource !== undefined
|
preferredSource !== undefined
|
||||||
? preferredSource
|
? preferredSource
|
||||||
: (sourceSelect ? sourceSelect.value : "")
|
: (sourceSelect ? sourceSelect.value : "")
|
||||||
);
|
);
|
||||||
const customValue = customText ? String(customText.value || "").trim() : "";
|
const customValue = customText ? String(customText.value || "").trim() : "";
|
||||||
const showCustom = selectedSource === "custom";
|
const showCustom = selectedSource === "custom";
|
||||||
@@ -2734,8 +2881,8 @@
|
|||||||
const selectedPerson = selected.dataset.person || thread.dataset.person || "";
|
const selectedPerson = selected.dataset.person || thread.dataset.person || "";
|
||||||
const selectedPageUrl = (
|
const selectedPageUrl = (
|
||||||
renderMode === "page"
|
renderMode === "page"
|
||||||
? selected.dataset.pageUrl
|
? selected.dataset.pageUrl
|
||||||
: selected.dataset.widgetUrl
|
: selected.dataset.widgetUrl
|
||||||
) || "";
|
) || "";
|
||||||
switchThreadContext(
|
switchThreadContext(
|
||||||
selectedService,
|
selectedService,
|
||||||
@@ -2764,8 +2911,8 @@
|
|||||||
const selectedPerson = selected.dataset.person || "";
|
const selectedPerson = selected.dataset.person || "";
|
||||||
let selectedPageUrl = (
|
let selectedPageUrl = (
|
||||||
renderMode === "page"
|
renderMode === "page"
|
||||||
? selected.dataset[servicePageUrlKey]
|
? selected.dataset[servicePageUrlKey]
|
||||||
: selected.dataset[serviceWidgetUrlKey]
|
: selected.dataset[serviceWidgetUrlKey]
|
||||||
) || "";
|
) || "";
|
||||||
if (!selectedIdentifier) {
|
if (!selectedIdentifier) {
|
||||||
selectedService = selected.dataset.service || selectedService;
|
selectedService = selected.dataset.service || selectedService;
|
||||||
@@ -2774,8 +2921,8 @@
|
|||||||
if (!selectedPageUrl) {
|
if (!selectedPageUrl) {
|
||||||
selectedPageUrl = (
|
selectedPageUrl = (
|
||||||
renderMode === "page"
|
renderMode === "page"
|
||||||
? selected.dataset.pageUrl
|
? selected.dataset.pageUrl
|
||||||
: selected.dataset.widgetUrl
|
: selected.dataset.widgetUrl
|
||||||
) || "";
|
) || "";
|
||||||
}
|
}
|
||||||
switchThreadContext(
|
switchThreadContext(
|
||||||
@@ -2877,6 +3024,51 @@
|
|||||||
textarea.focus();
|
textarea.focus();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Cancel send support: show a cancel button while the form request is pending.
|
||||||
|
let cancelBtn = null;
|
||||||
|
const showCancelButton = function () {
|
||||||
|
if (cancelBtn) return;
|
||||||
|
cancelBtn = document.createElement('button');
|
||||||
|
cancelBtn.type = 'button';
|
||||||
|
cancelBtn.className = 'button is-danger is-light is-small compose-cancel-send-btn';
|
||||||
|
cancelBtn.textContent = 'Cancel Send';
|
||||||
|
cancelBtn.addEventListener('click', function () {
|
||||||
|
// Post cancel by service+identifier
|
||||||
|
const payload = new URLSearchParams();
|
||||||
|
payload.set('service', thread.dataset.service || '');
|
||||||
|
payload.set('identifier', thread.dataset.identifier || '');
|
||||||
|
fetch('{% url "compose_cancel_send" %}', {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'same-origin',
|
||||||
|
headers: { 'X-CSRFToken': '{{ csrf_token }}', 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
|
body: payload.toString(),
|
||||||
|
}).then(function (resp) {
|
||||||
|
// Hide cancel once requested
|
||||||
|
hideCancelButton();
|
||||||
|
}).catch(function () {
|
||||||
|
hideCancelButton();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
if (statusBox) {
|
||||||
|
statusBox.appendChild(cancelBtn);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const hideCancelButton = function () {
|
||||||
|
if (!cancelBtn) return;
|
||||||
|
try { cancelBtn.remove(); } catch (e) {}
|
||||||
|
cancelBtn = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Show cancel on submit; htmx will make the request asynchronously.
|
||||||
|
form.addEventListener('submit', function (ev) {
|
||||||
|
// Only show when send confirmation allows
|
||||||
|
if (sendButton && sendButton.disabled) return;
|
||||||
|
showCancelButton();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Hide cancel after HTMX request completes
|
||||||
|
form.addEventListener('htmx:afterRequest', function () { hideCancelButton(); });
|
||||||
|
|
||||||
panelState.eventHandler = function (event) {
|
panelState.eventHandler = function (event) {
|
||||||
const detail = (event && event.detail) || {};
|
const detail = (event && event.detail) || {};
|
||||||
const sourcePanelId = String(detail.panel_id || "");
|
const sourcePanelId = String(detail.panel_id || "");
|
||||||
@@ -2887,6 +3079,114 @@
|
|||||||
};
|
};
|
||||||
document.body.addEventListener("composeMessageSent", panelState.eventHandler);
|
document.body.addEventListener("composeMessageSent", panelState.eventHandler);
|
||||||
|
|
||||||
|
// Persistent queued-command handling: when server returns composeSendCommandId
|
||||||
|
// HTMX will dispatch a `composeSendCommandId` event with detail {command_id: "..."}.
|
||||||
|
panelState.pendingCommandId = null;
|
||||||
|
panelState.pendingCommandPoll = null;
|
||||||
|
|
||||||
|
const startPendingCommandPolling = function (commandId) {
|
||||||
|
if (!commandId) return;
|
||||||
|
panelState.pendingCommandId = commandId;
|
||||||
|
// Show persistent cancel UI
|
||||||
|
showPersistentCancelButton(commandId);
|
||||||
|
// Poll for result every 1500ms
|
||||||
|
if (panelState.pendingCommandPoll) {
|
||||||
|
clearInterval(panelState.pendingCommandPoll);
|
||||||
|
}
|
||||||
|
panelState.pendingCommandPoll = setInterval(async function () {
|
||||||
|
try {
|
||||||
|
const url = new URL('{% url "compose_command_result" %}', window.location.origin);
|
||||||
|
url.searchParams.set('service', thread.dataset.service || '');
|
||||||
|
url.searchParams.set('command_id', commandId);
|
||||||
|
const resp = await fetch(url.toString(), { credentials: 'same-origin' });
|
||||||
|
if (!resp.ok) return;
|
||||||
|
const payload = await resp.json();
|
||||||
|
if (payload && payload.pending === false) {
|
||||||
|
// Stop polling
|
||||||
|
stopPendingCommandPolling();
|
||||||
|
// Hide cancel UI
|
||||||
|
hidePersistentCancelButton();
|
||||||
|
// Surface result to the user
|
||||||
|
const result = payload.result || {};
|
||||||
|
if (result.ok) {
|
||||||
|
setStatus('', 'success');
|
||||||
|
textarea.value = '';
|
||||||
|
autosize();
|
||||||
|
flashCompose('is-send-success');
|
||||||
|
poll(true);
|
||||||
|
} else {
|
||||||
|
const msg = String(result.error || 'Send failed.');
|
||||||
|
setStatus(msg, 'danger');
|
||||||
|
flashCompose('is-send-fail');
|
||||||
|
poll(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// ignore transient network errors
|
||||||
|
}
|
||||||
|
}, 1500);
|
||||||
|
};
|
||||||
|
|
||||||
|
const stopPendingCommandPolling = function () {
|
||||||
|
if (panelState.pendingCommandPoll) {
|
||||||
|
clearInterval(panelState.pendingCommandPoll);
|
||||||
|
panelState.pendingCommandPoll = null;
|
||||||
|
}
|
||||||
|
panelState.pendingCommandId = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const persistentCancelContainerId = panelId + '-persistent-cancel';
|
||||||
|
const showPersistentCancelButton = function (commandId) {
|
||||||
|
hidePersistentCancelButton();
|
||||||
|
const container = document.createElement('div');
|
||||||
|
container.id = persistentCancelContainerId;
|
||||||
|
container.style.marginTop = '0.35rem';
|
||||||
|
const btn = document.createElement('button');
|
||||||
|
btn.type = 'button';
|
||||||
|
btn.className = 'button is-danger is-light is-small compose-persistent-cancel-btn';
|
||||||
|
btn.textContent = 'Cancel Queued Send';
|
||||||
|
btn.addEventListener('click', function () {
|
||||||
|
const payload = new URLSearchParams();
|
||||||
|
payload.set('service', thread.dataset.service || '');
|
||||||
|
payload.set('identifier', thread.dataset.identifier || '');
|
||||||
|
payload.set('command_id', String(commandId || ''));
|
||||||
|
fetch('{% url "compose_cancel_send" %}', {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'same-origin',
|
||||||
|
headers: { 'X-CSRFToken': '{{ csrf_token }}', 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
|
body: payload.toString(),
|
||||||
|
}).then(function (resp) {
|
||||||
|
stopPendingCommandPolling();
|
||||||
|
hidePersistentCancelButton();
|
||||||
|
setStatus('Send cancelled.', 'warning');
|
||||||
|
poll(true);
|
||||||
|
}).catch(function () {
|
||||||
|
hidePersistentCancelButton();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
container.appendChild(btn);
|
||||||
|
if (statusBox) {
|
||||||
|
statusBox.appendChild(container);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const hidePersistentCancelButton = function () {
|
||||||
|
try {
|
||||||
|
const el = document.getElementById(persistentCancelContainerId);
|
||||||
|
if (el) el.remove();
|
||||||
|
} catch (e) {}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.body.addEventListener('composeSendCommandId', function (ev) {
|
||||||
|
try {
|
||||||
|
const detail = (ev && ev.detail) || {};
|
||||||
|
const cmd = (detail && detail.command_id) || (detail && detail.composeSendCommandId && detail.composeSendCommandId.command_id) || null;
|
||||||
|
if (cmd) {
|
||||||
|
startPendingCommandPolling(String(cmd));
|
||||||
|
}
|
||||||
|
} catch (e) {}
|
||||||
|
});
|
||||||
|
|
||||||
panelState.sendResultHandler = function (event) {
|
panelState.sendResultHandler = function (event) {
|
||||||
const detail = (event && event.detail) || {};
|
const detail = (event && event.detail) || {};
|
||||||
const sourcePanelId = String(detail.panel_id || "");
|
const sourcePanelId = String(detail.panel_id || "");
|
||||||
|
|||||||
@@ -4,8 +4,9 @@ import hashlib
|
|||||||
import json
|
import json
|
||||||
import re
|
import re
|
||||||
import time
|
import time
|
||||||
|
from datetime import datetime
|
||||||
|
from datetime import timezone as dt_timezone
|
||||||
from difflib import SequenceMatcher
|
from difflib import SequenceMatcher
|
||||||
from datetime import datetime, timezone as dt_timezone
|
|
||||||
from urllib.parse import quote_plus, urlencode, urlparse
|
from urllib.parse import quote_plus, urlencode, urlparse
|
||||||
|
|
||||||
from asgiref.sync import async_to_sync
|
from asgiref.sync import async_to_sync
|
||||||
@@ -40,7 +41,11 @@ from core.models import (
|
|||||||
WorkspaceConversation,
|
WorkspaceConversation,
|
||||||
)
|
)
|
||||||
from core.realtime.typing_state import get_person_typing_state
|
from core.realtime.typing_state import get_person_typing_state
|
||||||
from core.views.workspace import INSIGHT_METRICS, _build_engage_payload, _parse_draft_options
|
from core.views.workspace import (
|
||||||
|
INSIGHT_METRICS,
|
||||||
|
_build_engage_payload,
|
||||||
|
_parse_draft_options,
|
||||||
|
)
|
||||||
|
|
||||||
COMPOSE_WS_TOKEN_SALT = "compose-ws"
|
COMPOSE_WS_TOKEN_SALT = "compose-ws"
|
||||||
COMPOSE_ENGAGE_TOKEN_SALT = "compose-engage"
|
COMPOSE_ENGAGE_TOKEN_SALT = "compose-engage"
|
||||||
@@ -129,7 +134,9 @@ def _extract_urls(text_value: str) -> list[str]:
|
|||||||
|
|
||||||
|
|
||||||
def _is_url_only_text(text_value: str) -> bool:
|
def _is_url_only_text(text_value: str) -> bool:
|
||||||
lines = [line.strip() for line in str(text_value or "").splitlines() if line.strip()]
|
lines = [
|
||||||
|
line.strip() for line in str(text_value or "").splitlines() if line.strip()
|
||||||
|
]
|
||||||
if not lines:
|
if not lines:
|
||||||
return False
|
return False
|
||||||
return all(bool(URL_PATTERN.fullmatch(line)) for line in lines)
|
return all(bool(URL_PATTERN.fullmatch(line)) for line in lines)
|
||||||
@@ -150,10 +157,14 @@ def _is_xmpp_share_url(url_value: str) -> bool:
|
|||||||
return False
|
return False
|
||||||
parsed = urlparse(url_value)
|
parsed = urlparse(url_value)
|
||||||
host = str(parsed.netloc or "").strip().lower()
|
host = str(parsed.netloc or "").strip().lower()
|
||||||
configured = str(
|
configured = (
|
||||||
getattr(settings, "XMPP_UPLOAD_SERVICE", "")
|
str(
|
||||||
or getattr(settings, "XMPP_UPLOAD_JID", "")
|
getattr(settings, "XMPP_UPLOAD_SERVICE", "")
|
||||||
).strip().lower()
|
or getattr(settings, "XMPP_UPLOAD_JID", "")
|
||||||
|
)
|
||||||
|
.strip()
|
||||||
|
.lower()
|
||||||
|
)
|
||||||
if not configured:
|
if not configured:
|
||||||
return False
|
return False
|
||||||
configured_host = configured
|
configured_host = configured
|
||||||
@@ -200,15 +211,21 @@ def _extract_attachment_image_urls(blob) -> list[str]:
|
|||||||
return urls
|
return urls
|
||||||
|
|
||||||
if isinstance(blob, dict):
|
if isinstance(blob, dict):
|
||||||
content_type = str(
|
content_type = (
|
||||||
blob.get("content_type")
|
str(
|
||||||
or blob.get("contentType")
|
blob.get("content_type")
|
||||||
or blob.get("mime_type")
|
or blob.get("contentType")
|
||||||
or blob.get("mimetype")
|
or blob.get("mime_type")
|
||||||
or ""
|
or blob.get("mimetype")
|
||||||
).strip().lower()
|
or ""
|
||||||
|
)
|
||||||
|
.strip()
|
||||||
|
.lower()
|
||||||
|
)
|
||||||
filename = str(blob.get("filename") or blob.get("fileName") or "").strip()
|
filename = str(blob.get("filename") or blob.get("fileName") or "").strip()
|
||||||
image_hint = content_type.startswith("image/") or _looks_like_image_name(filename)
|
image_hint = content_type.startswith("image/") or _looks_like_image_name(
|
||||||
|
filename
|
||||||
|
)
|
||||||
|
|
||||||
direct_urls = []
|
direct_urls = []
|
||||||
for key in ("url", "source_url", "download_url", "proxy_url", "href", "uri"):
|
for key in ("url", "source_url", "download_url", "proxy_url", "href", "uri"):
|
||||||
@@ -264,7 +281,9 @@ def _attachment_image_urls_by_message(messages):
|
|||||||
).order_by("ts")
|
).order_by("ts")
|
||||||
|
|
||||||
for event in linked_events:
|
for event in linked_events:
|
||||||
legacy_id = str((event.raw_payload_ref or {}).get("legacy_message_id") or "").strip()
|
legacy_id = str(
|
||||||
|
(event.raw_payload_ref or {}).get("legacy_message_id") or ""
|
||||||
|
).strip()
|
||||||
if not legacy_id:
|
if not legacy_id:
|
||||||
continue
|
continue
|
||||||
urls = _uniq_ordered(
|
urls = _uniq_ordered(
|
||||||
@@ -296,9 +315,7 @@ def _attachment_image_urls_by_message(messages):
|
|||||||
continue
|
continue
|
||||||
msg_ts = int(msg.ts or 0)
|
msg_ts = int(msg.ts or 0)
|
||||||
candidates = [
|
candidates = [
|
||||||
event
|
event for event in fallback_list if abs(int(event.ts or 0) - msg_ts) <= 3000
|
||||||
for event in fallback_list
|
|
||||||
if abs(int(event.ts or 0) - msg_ts) <= 3000
|
|
||||||
]
|
]
|
||||||
if not candidates:
|
if not candidates:
|
||||||
continue
|
continue
|
||||||
@@ -322,8 +339,51 @@ def _serialize_message(msg: Message) -> dict:
|
|||||||
and _is_url_only_text(text_value)
|
and _is_url_only_text(text_value)
|
||||||
and all(_looks_like_image_url(url) for url in image_urls)
|
and all(_looks_like_image_url(url) for url in image_urls)
|
||||||
)
|
)
|
||||||
display_text = text_value if text_value.strip() else ("(no text)" if not image_url else "")
|
display_text = (
|
||||||
|
text_value if text_value.strip() else ("(no text)" if not image_url else "")
|
||||||
|
)
|
||||||
author = str(msg.custom_author or "").strip()
|
author = str(msg.custom_author or "").strip()
|
||||||
|
is_outgoing = _is_outgoing(msg)
|
||||||
|
|
||||||
|
# Determine source service for display: prefer explicit session identifier service
|
||||||
|
source_service = "web"
|
||||||
|
try:
|
||||||
|
if getattr(msg, "session", None) and getattr(msg.session, "identifier", None):
|
||||||
|
svc = str(msg.session.identifier.service or "").strip().lower()
|
||||||
|
if svc:
|
||||||
|
source_service = svc
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
from core.util import logs as util_logs
|
||||||
|
|
||||||
|
logger = util_logs.get_logger("compose")
|
||||||
|
logger.info(
|
||||||
|
f"[serialize_message] id={msg.id} author={author} is_outgoing={is_outgoing} source_service={source_service}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# For outgoing messages sent from web UI, label as "Web Chat".
|
||||||
|
# For incoming messages, use the session's service name (Xmpp, Signal, Whatsapp, etc).
|
||||||
|
# But if source_service is still "web" and message is incoming, it may be a data issue—
|
||||||
|
# don't label it as "Web Chat" since that's misleading.
|
||||||
|
if is_outgoing:
|
||||||
|
source_label = "Web Chat"
|
||||||
|
else:
|
||||||
|
# Incoming message: use service-specific labels
|
||||||
|
service_labels = {
|
||||||
|
"xmpp": "XMPP",
|
||||||
|
"whatsapp": "WhatsApp",
|
||||||
|
"signal": "Signal",
|
||||||
|
"instagram": "Instagram",
|
||||||
|
"web": "External", # Fallback if service not identified
|
||||||
|
}
|
||||||
|
source_label = service_labels.get(
|
||||||
|
source_service, source_service.title() if source_service else "Unknown"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Ensure source_label is never empty for UI rendering
|
||||||
|
if not source_label:
|
||||||
|
source_label = "Unknown"
|
||||||
delivered_ts = int(msg.delivered_ts or 0)
|
delivered_ts = int(msg.delivered_ts or 0)
|
||||||
read_ts = int(msg.read_ts or 0)
|
read_ts = int(msg.read_ts or 0)
|
||||||
delivered_display = _format_ts_label(int(delivered_ts)) if delivered_ts else ""
|
delivered_display = _format_ts_label(int(delivered_ts)) if delivered_ts else ""
|
||||||
@@ -331,6 +391,17 @@ def _serialize_message(msg: Message) -> dict:
|
|||||||
ts_val = int(msg.ts or 0)
|
ts_val = int(msg.ts or 0)
|
||||||
delivered_delta = int(delivered_ts - ts_val) if delivered_ts and ts_val else None
|
delivered_delta = int(delivered_ts - ts_val) if delivered_ts and ts_val else None
|
||||||
read_delta = int(read_ts - ts_val) if read_ts and ts_val else None
|
read_delta = int(read_ts - ts_val) if read_ts and ts_val else None
|
||||||
|
# Human friendly delta strings
|
||||||
|
delivered_delta_display = (
|
||||||
|
_format_gap_duration(delivered_delta) if delivered_delta is not None else ""
|
||||||
|
)
|
||||||
|
read_delta_display = (
|
||||||
|
_format_gap_duration(read_delta) if read_delta is not None else ""
|
||||||
|
)
|
||||||
|
# Receipt payload and metadata
|
||||||
|
receipt_payload = msg.receipt_payload or {}
|
||||||
|
read_source_service = str(msg.read_source_service or "").strip()
|
||||||
|
read_by_identifier = str(msg.read_by_identifier or "").strip()
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"id": str(msg.id),
|
"id": str(msg.id),
|
||||||
@@ -343,12 +414,19 @@ def _serialize_message(msg: Message) -> dict:
|
|||||||
"hide_text": hide_text,
|
"hide_text": hide_text,
|
||||||
"author": author,
|
"author": author,
|
||||||
"outgoing": _is_outgoing(msg),
|
"outgoing": _is_outgoing(msg),
|
||||||
|
"source_service": source_service,
|
||||||
|
"source_label": source_label,
|
||||||
"delivered_ts": delivered_ts,
|
"delivered_ts": delivered_ts,
|
||||||
"read_ts": read_ts,
|
"read_ts": read_ts,
|
||||||
"delivered_display": delivered_display,
|
"delivered_display": delivered_display,
|
||||||
"read_display": read_display,
|
"read_display": read_display,
|
||||||
"delivered_delta": delivered_delta,
|
"delivered_delta": delivered_delta,
|
||||||
"read_delta": read_delta,
|
"read_delta": read_delta,
|
||||||
|
"delivered_delta_display": delivered_delta_display,
|
||||||
|
"read_delta_display": read_delta_display,
|
||||||
|
"receipt_payload": receipt_payload,
|
||||||
|
"read_source_service": read_source_service,
|
||||||
|
"read_by_identifier": read_by_identifier,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -510,9 +588,8 @@ def _workspace_conversation_for_person(user, person):
|
|||||||
def _counterpart_identifiers_for_person(user, person):
|
def _counterpart_identifiers_for_person(user, person):
|
||||||
if person is None:
|
if person is None:
|
||||||
return set()
|
return set()
|
||||||
values = (
|
values = PersonIdentifier.objects.filter(user=user, person=person).values_list(
|
||||||
PersonIdentifier.objects.filter(user=user, person=person)
|
"identifier", flat=True
|
||||||
.values_list("identifier", flat=True)
|
|
||||||
)
|
)
|
||||||
return {str(value or "").strip() for value in values if str(value or "").strip()}
|
return {str(value or "").strip() for value in values if str(value or "").strip()}
|
||||||
|
|
||||||
@@ -598,13 +675,17 @@ def _build_thread_metric_fragments(conversation):
|
|||||||
|
|
||||||
|
|
||||||
def _build_gap_fragment(is_outgoing_reply, lag_ms, snapshot):
|
def _build_gap_fragment(is_outgoing_reply, lag_ms, snapshot):
|
||||||
metric_slug = "outbound_response_score" if is_outgoing_reply else "inbound_response_score"
|
metric_slug = (
|
||||||
|
"outbound_response_score" if is_outgoing_reply else "inbound_response_score"
|
||||||
|
)
|
||||||
copy = _metric_copy(metric_slug, "Response Score")
|
copy = _metric_copy(metric_slug, "Response Score")
|
||||||
score_value = None
|
score_value = None
|
||||||
if snapshot is not None:
|
if snapshot is not None:
|
||||||
score_value = getattr(
|
score_value = getattr(
|
||||||
snapshot,
|
snapshot,
|
||||||
"outbound_response_score" if is_outgoing_reply else "inbound_response_score",
|
"outbound_response_score"
|
||||||
|
if is_outgoing_reply
|
||||||
|
else "inbound_response_score",
|
||||||
None,
|
None,
|
||||||
)
|
)
|
||||||
if score_value is None:
|
if score_value is None:
|
||||||
@@ -651,7 +732,9 @@ def _serialize_messages_with_artifacts(
|
|||||||
item["metric_fragments"] = []
|
item["metric_fragments"] = []
|
||||||
|
|
||||||
counterpart_identifiers = set(counterpart_identifiers or [])
|
counterpart_identifiers = set(counterpart_identifiers or [])
|
||||||
snapshot = conversation.metric_snapshots.first() if conversation is not None else None
|
snapshot = (
|
||||||
|
conversation.metric_snapshots.first() if conversation is not None else None
|
||||||
|
)
|
||||||
|
|
||||||
prev_msg = seed_previous
|
prev_msg = seed_previous
|
||||||
prev_ts = int(prev_msg.ts or 0) if prev_msg is not None else None
|
prev_ts = int(prev_msg.ts or 0) if prev_msg is not None else None
|
||||||
@@ -663,7 +746,9 @@ def _serialize_messages_with_artifacts(
|
|||||||
|
|
||||||
for idx, msg in enumerate(rows):
|
for idx, msg in enumerate(rows):
|
||||||
current_ts = int(msg.ts or 0)
|
current_ts = int(msg.ts or 0)
|
||||||
current_outgoing = _message_is_outgoing_for_analysis(msg, counterpart_identifiers)
|
current_outgoing = _message_is_outgoing_for_analysis(
|
||||||
|
msg, counterpart_identifiers
|
||||||
|
)
|
||||||
if (
|
if (
|
||||||
prev_msg is not None
|
prev_msg is not None
|
||||||
and prev_ts is not None
|
and prev_ts is not None
|
||||||
@@ -680,7 +765,9 @@ def _serialize_messages_with_artifacts(
|
|||||||
prev_outgoing = current_outgoing
|
prev_outgoing = current_outgoing
|
||||||
|
|
||||||
if serialized:
|
if serialized:
|
||||||
serialized[-1]["metric_fragments"] = _build_thread_metric_fragments(conversation)
|
serialized[-1]["metric_fragments"] = _build_thread_metric_fragments(
|
||||||
|
conversation
|
||||||
|
)
|
||||||
|
|
||||||
return serialized
|
return serialized
|
||||||
|
|
||||||
@@ -770,12 +857,7 @@ def _build_glance_items(serialized_messages, person_id=None):
|
|||||||
|
|
||||||
|
|
||||||
def _owner_name(user) -> str:
|
def _owner_name(user) -> str:
|
||||||
return (
|
return user.first_name or user.get_full_name().strip() or user.username or "Me"
|
||||||
user.first_name
|
|
||||||
or user.get_full_name().strip()
|
|
||||||
or user.username
|
|
||||||
or "Me"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _compose_ws_token(user_id, service, identifier, person_id):
|
def _compose_ws_token(user_id, service, identifier, person_id):
|
||||||
@@ -789,7 +871,9 @@ def _compose_ws_token(user_id, service, identifier, person_id):
|
|||||||
return signing.dumps(payload, salt=COMPOSE_WS_TOKEN_SALT)
|
return signing.dumps(payload, salt=COMPOSE_WS_TOKEN_SALT)
|
||||||
|
|
||||||
|
|
||||||
def _compose_ai_cache_key(kind, user_id, service, identifier, person_id, last_ts, limit):
|
def _compose_ai_cache_key(
|
||||||
|
kind, user_id, service, identifier, person_id, last_ts, limit
|
||||||
|
):
|
||||||
raw = "|".join(
|
raw = "|".join(
|
||||||
[
|
[
|
||||||
str(kind or ""),
|
str(kind or ""),
|
||||||
@@ -825,7 +909,9 @@ def _engage_body_only(value):
|
|||||||
def _messages_for_ai(user, person_identifier, limit):
|
def _messages_for_ai(user, person_identifier, limit):
|
||||||
if person_identifier is None:
|
if person_identifier is None:
|
||||||
return []
|
return []
|
||||||
session, _ = ChatSession.objects.get_or_create(user=user, identifier=person_identifier)
|
session, _ = ChatSession.objects.get_or_create(
|
||||||
|
user=user, identifier=person_identifier
|
||||||
|
)
|
||||||
rows = list(
|
rows = list(
|
||||||
Message.objects.filter(user=user, session=session)
|
Message.objects.filter(user=user, session=session)
|
||||||
.select_related("session", "session__identifier", "session__identifier__person")
|
.select_related("session", "session__identifier", "session__identifier__person")
|
||||||
@@ -949,7 +1035,9 @@ def _trend_meta(current, previous, higher_is_better=True):
|
|||||||
improves = is_up if higher_is_better else not is_up
|
improves = is_up if higher_is_better else not is_up
|
||||||
return {
|
return {
|
||||||
"direction": "up" if is_up else "down",
|
"direction": "up" if is_up else "down",
|
||||||
"icon": "fa-solid fa-arrow-trend-up" if is_up else "fa-solid fa-arrow-trend-down",
|
"icon": "fa-solid fa-arrow-trend-up"
|
||||||
|
if is_up
|
||||||
|
else "fa-solid fa-arrow-trend-down",
|
||||||
"class_name": "has-text-success" if improves else "has-text-danger",
|
"class_name": "has-text-success" if improves else "has-text-danger",
|
||||||
"meaning": "Improving signal" if improves else "Risk signal",
|
"meaning": "Improving signal" if improves else "Risk signal",
|
||||||
}
|
}
|
||||||
@@ -1443,7 +1531,9 @@ def _manual_contact_rows(user):
|
|||||||
if key in seen:
|
if key in seen:
|
||||||
return
|
return
|
||||||
seen.add(key)
|
seen.add(key)
|
||||||
urls = _compose_urls(service_key, identifier_value, person.id if person else None)
|
urls = _compose_urls(
|
||||||
|
service_key, identifier_value, person.id if person else None
|
||||||
|
)
|
||||||
linked_person_name = person.name if person else ""
|
linked_person_name = person.name if person else ""
|
||||||
detected = _clean_detected_name(detected_name or account or "")
|
detected = _clean_detected_name(detected_name or account or "")
|
||||||
person_name = linked_person_name or detected or identifier_value
|
person_name = linked_person_name or detected or identifier_value
|
||||||
@@ -1502,7 +1592,9 @@ def _manual_contact_rows(user):
|
|||||||
person=(linked.person if linked else None),
|
person=(linked.person if linked else None),
|
||||||
source="signal_chat",
|
source="signal_chat",
|
||||||
account=str(chat.account or ""),
|
account=str(chat.account or ""),
|
||||||
detected_name=_clean_detected_name(chat.source_name or chat.account or ""),
|
detected_name=_clean_detected_name(
|
||||||
|
chat.source_name or chat.account or ""
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
whatsapp_links = {
|
whatsapp_links = {
|
||||||
@@ -1529,7 +1621,9 @@ def _manual_contact_rows(user):
|
|||||||
continue
|
continue
|
||||||
if _normalize_contact_key(candidate) in wa_account_keys:
|
if _normalize_contact_key(candidate) in wa_account_keys:
|
||||||
continue
|
continue
|
||||||
detected_name = _clean_detected_name(item.get("name") or item.get("chat") or "")
|
detected_name = _clean_detected_name(
|
||||||
|
item.get("name") or item.get("chat") or ""
|
||||||
|
)
|
||||||
if detected_name.lower() == "linked account":
|
if detected_name.lower() == "linked account":
|
||||||
continue
|
continue
|
||||||
linked = whatsapp_links.get(candidate)
|
linked = whatsapp_links.get(candidate)
|
||||||
@@ -1572,7 +1666,10 @@ def _recent_manual_contacts(
|
|||||||
current_person_id = str(current_person.id) if current_person else ""
|
current_person_id = str(current_person.id) if current_person else ""
|
||||||
|
|
||||||
row_by_key = {
|
row_by_key = {
|
||||||
(str(row.get("service") or "").strip().lower(), str(row.get("identifier") or "").strip()): row
|
(
|
||||||
|
str(row.get("service") or "").strip().lower(),
|
||||||
|
str(row.get("identifier") or "").strip(),
|
||||||
|
): row
|
||||||
for row in all_rows
|
for row in all_rows
|
||||||
}
|
}
|
||||||
by_person_service = {}
|
by_person_service = {}
|
||||||
@@ -1716,8 +1813,12 @@ def _recent_manual_contacts(
|
|||||||
seen_unknown.add(unknown_key)
|
seen_unknown.add(unknown_key)
|
||||||
row["service_label"] = _service_label(service_key)
|
row["service_label"] = _service_label(service_key)
|
||||||
for svc in ("signal", "whatsapp", "instagram", "xmpp"):
|
for svc in ("signal", "whatsapp", "instagram", "xmpp"):
|
||||||
row[f"{svc}_identifier"] = identifier_value if svc == service_key else ""
|
row[f"{svc}_identifier"] = (
|
||||||
row[f"{svc}_compose_url"] = row.get("compose_url") if svc == service_key else ""
|
identifier_value if svc == service_key else ""
|
||||||
|
)
|
||||||
|
row[f"{svc}_compose_url"] = (
|
||||||
|
row.get("compose_url") if svc == service_key else ""
|
||||||
|
)
|
||||||
row[f"{svc}_compose_widget_url"] = (
|
row[f"{svc}_compose_widget_url"] = (
|
||||||
row.get("compose_widget_url") if svc == service_key else ""
|
row.get("compose_widget_url") if svc == service_key else ""
|
||||||
)
|
)
|
||||||
@@ -1855,7 +1956,9 @@ def _panel_context(
|
|||||||
|
|
||||||
for service_key in sorted(by_service.keys(), key=_service_order):
|
for service_key in sorted(by_service.keys(), key=_service_order):
|
||||||
identifier_value = by_service[service_key]
|
identifier_value = by_service[service_key]
|
||||||
option_urls = _compose_urls(service_key, identifier_value, base["person"].id)
|
option_urls = _compose_urls(
|
||||||
|
service_key, identifier_value, base["person"].id
|
||||||
|
)
|
||||||
platform_options.append(
|
platform_options.append(
|
||||||
{
|
{
|
||||||
"service": service_key,
|
"service": service_key,
|
||||||
@@ -2122,7 +2225,9 @@ class ComposeContactMatch(LoginRequiredMixin, View):
|
|||||||
row.save(update_fields=["person"])
|
row.save(update_fields=["person"])
|
||||||
message = f"Re-linked {identifier} ({service}) to {person.name}."
|
message = f"Re-linked {identifier} ({service}) to {person.name}."
|
||||||
else:
|
else:
|
||||||
message = f"{identifier} ({service}) is already linked to {person.name}."
|
message = (
|
||||||
|
f"{identifier} ({service}) is already linked to {person.name}."
|
||||||
|
)
|
||||||
|
|
||||||
linked_companions = 0
|
linked_companions = 0
|
||||||
skipped_companions = 0
|
skipped_companions = 0
|
||||||
@@ -2247,7 +2352,8 @@ class ComposeThread(LoginRequiredMixin, View):
|
|||||||
user=request.user,
|
user=request.user,
|
||||||
identifier=base["person_identifier"],
|
identifier=base["person_identifier"],
|
||||||
)
|
)
|
||||||
session_ids = list({*session_ids, int(session.id)})
|
# Don't convert UUIDs to int; keep them as UUIDs for the filter query
|
||||||
|
session_ids = list({*session_ids, session.id})
|
||||||
if session_ids:
|
if session_ids:
|
||||||
base_queryset = Message.objects.filter(
|
base_queryset = Message.objects.filter(
|
||||||
user=request.user,
|
user=request.user,
|
||||||
@@ -2264,8 +2370,7 @@ class ComposeThread(LoginRequiredMixin, View):
|
|||||||
"session",
|
"session",
|
||||||
"session__identifier",
|
"session__identifier",
|
||||||
"session__identifier__person",
|
"session__identifier__person",
|
||||||
)
|
).order_by("ts")[:limit]
|
||||||
.order_by("ts")[:limit]
|
|
||||||
)
|
)
|
||||||
newest = (
|
newest = (
|
||||||
Message.objects.filter(
|
Message.objects.filter(
|
||||||
@@ -2328,6 +2433,7 @@ class ComposeHistorySync(LoginRequiredMixin, View):
|
|||||||
values.add(local)
|
values.add(local)
|
||||||
return [value for value in values if value]
|
return [value for value in values if value]
|
||||||
|
|
||||||
|
@classmethod
|
||||||
@classmethod
|
@classmethod
|
||||||
def _session_ids_for_scope(
|
def _session_ids_for_scope(
|
||||||
cls,
|
cls,
|
||||||
@@ -2370,12 +2476,13 @@ class ComposeHistorySync(LoginRequiredMixin, View):
|
|||||||
unique_ids.append(row_id)
|
unique_ids.append(row_id)
|
||||||
if not unique_ids:
|
if not unique_ids:
|
||||||
return []
|
return []
|
||||||
return list(
|
result = list(
|
||||||
ChatSession.objects.filter(
|
ChatSession.objects.filter(
|
||||||
user=user,
|
user=user,
|
||||||
identifier_id__in=unique_ids,
|
identifier_id__in=unique_ids,
|
||||||
).values_list("id", flat=True)
|
).values_list("id", flat=True)
|
||||||
)
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _reconcile_duplicate_messages(user, session_ids):
|
def _reconcile_duplicate_messages(user, session_ids):
|
||||||
@@ -2417,7 +2524,11 @@ class ComposeHistorySync(LoginRequiredMixin, View):
|
|||||||
person = get_object_or_404(Person, id=person_id, user=request.user)
|
person = get_object_or_404(Person, id=person_id, user=request.user)
|
||||||
if not identifier and person is None:
|
if not identifier and person is None:
|
||||||
return JsonResponse(
|
return JsonResponse(
|
||||||
{"ok": False, "message": "Missing contact identifier.", "level": "danger"}
|
{
|
||||||
|
"ok": False,
|
||||||
|
"message": "Missing contact identifier.",
|
||||||
|
"level": "danger",
|
||||||
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
base = _context_base(request.user, service, identifier, person)
|
base = _context_base(request.user, service, identifier, person)
|
||||||
@@ -2575,6 +2686,44 @@ class ComposeHistorySync(LoginRequiredMixin, View):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ComposeCancelSend(LoginRequiredMixin, View):
|
||||||
|
def post(self, request):
|
||||||
|
service = _default_service(request.POST.get("service"))
|
||||||
|
identifier = str(request.POST.get("identifier") or "").strip()
|
||||||
|
command_id = str(request.POST.get("command_id") or "").strip()
|
||||||
|
if not identifier:
|
||||||
|
return JsonResponse({"ok": False, "error": "missing_identifier"})
|
||||||
|
# If a specific command_id is supplied, cancel that command only.
|
||||||
|
if command_id:
|
||||||
|
ok = transport.cancel_runtime_command(service, command_id)
|
||||||
|
return JsonResponse({"ok": True, "cancelled": [command_id] if ok else []})
|
||||||
|
cancelled = transport.cancel_runtime_commands_for_recipient(service, identifier)
|
||||||
|
return JsonResponse({"ok": True, "cancelled": cancelled})
|
||||||
|
|
||||||
|
|
||||||
|
class ComposeCommandResult(LoginRequiredMixin, View):
|
||||||
|
"""Return the runtime command result for a queued send (if available).
|
||||||
|
|
||||||
|
GET parameters: `service`, `command_id`.
|
||||||
|
Returns JSON: if pending -> {"pending": True}, else returns the result dict.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def get(self, request):
|
||||||
|
service = _default_service(request.GET.get("service"))
|
||||||
|
command_id = str(request.GET.get("command_id") or "").strip()
|
||||||
|
if not command_id:
|
||||||
|
return JsonResponse(
|
||||||
|
{"ok": False, "error": "missing_command_id"}, status=400
|
||||||
|
)
|
||||||
|
# Non-blocking check for runtime command result
|
||||||
|
result = async_to_sync(transport.wait_runtime_command_result)(
|
||||||
|
service, command_id, timeout=0.1
|
||||||
|
)
|
||||||
|
if result is None:
|
||||||
|
return JsonResponse({"pending": True})
|
||||||
|
return JsonResponse({"pending": False, "result": result})
|
||||||
|
|
||||||
|
|
||||||
class ComposeMediaBlob(LoginRequiredMixin, View):
|
class ComposeMediaBlob(LoginRequiredMixin, View):
|
||||||
"""
|
"""
|
||||||
Serve cached media blobs for authenticated compose image previews.
|
Serve cached media blobs for authenticated compose image previews.
|
||||||
@@ -2773,21 +2922,23 @@ class ComposeQuickInsights(LoginRequiredMixin, View):
|
|||||||
"thread": "",
|
"thread": "",
|
||||||
"last_event": "",
|
"last_event": "",
|
||||||
"last_ai_run": "",
|
"last_ai_run": "",
|
||||||
"workspace_created": "",
|
"workspace_created": "",
|
||||||
"snapshot_count": 0,
|
"snapshot_count": 0,
|
||||||
"platform_docs": _metric_copy("platform", "Platform"),
|
"platform_docs": _metric_copy("platform", "Platform"),
|
||||||
"state_docs": _metric_copy("stability_state", "Participant State"),
|
"state_docs": _metric_copy(
|
||||||
"thread_docs": _metric_copy("thread", "Thread"),
|
"stability_state", "Participant State"
|
||||||
"snapshot_docs": {
|
|
||||||
"calculation": (
|
|
||||||
"Count of stored workspace metric snapshots for this person."
|
|
||||||
),
|
|
||||||
"psychology": (
|
|
||||||
"More points improve trend reliability; sparse points are "
|
|
||||||
"best treated as directional signals."
|
|
||||||
),
|
),
|
||||||
|
"thread_docs": _metric_copy("thread", "Thread"),
|
||||||
|
"snapshot_docs": {
|
||||||
|
"calculation": (
|
||||||
|
"Count of stored workspace metric snapshots for this person."
|
||||||
|
),
|
||||||
|
"psychology": (
|
||||||
|
"More points improve trend reliability; sparse points are "
|
||||||
|
"best treated as directional signals."
|
||||||
|
),
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
|
||||||
"rows": [],
|
"rows": [],
|
||||||
"docs": [
|
"docs": [
|
||||||
"Quick Insights needs at least one workspace conversation snapshot.",
|
"Quick Insights needs at least one workspace conversation snapshot.",
|
||||||
@@ -2935,7 +3086,9 @@ class ComposeEngagePreview(LoginRequiredMixin, View):
|
|||||||
)
|
)
|
||||||
preview = str(payload.get("preview") or "").strip()
|
preview = str(payload.get("preview") or "").strip()
|
||||||
outbound = _engage_body_only(payload.get("outbound") or "")
|
outbound = _engage_body_only(payload.get("outbound") or "")
|
||||||
artifact_label = f"{source_kind.title()}: {getattr(source_obj, 'title', '')}"
|
artifact_label = (
|
||||||
|
f"{source_kind.title()}: {getattr(source_obj, 'title', '')}"
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
ai_obj = AI.objects.filter(user=request.user).first()
|
ai_obj = AI.objects.filter(user=request.user).first()
|
||||||
if ai_obj is not None:
|
if ai_obj is not None:
|
||||||
@@ -3062,6 +3215,11 @@ class ComposeSend(LoginRequiredMixin, View):
|
|||||||
"panel_id": str(panel_id or ""),
|
"panel_id": str(panel_id or ""),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
# Optional: include command id to allow client-side cancellation UI
|
||||||
|
if hasattr(request, "_compose_command_id") and request._compose_command_id:
|
||||||
|
trigger_payload["composeSendCommandId"] = {
|
||||||
|
"command_id": str(request._compose_command_id)
|
||||||
|
}
|
||||||
if ok:
|
if ok:
|
||||||
trigger_payload["composeMessageSent"] = {"panel_id": str(panel_id or "")}
|
trigger_payload["composeMessageSent"] = {"panel_id": str(panel_id or "")}
|
||||||
response["HX-Trigger"] = json.dumps(trigger_payload)
|
response["HX-Trigger"] = json.dumps(trigger_payload)
|
||||||
@@ -3104,12 +3262,48 @@ class ComposeSend(LoginRequiredMixin, View):
|
|||||||
)
|
)
|
||||||
|
|
||||||
base = _context_base(request.user, service, identifier, person)
|
base = _context_base(request.user, service, identifier, person)
|
||||||
ts = async_to_sync(transport.send_message_raw)(
|
from core.util import logs as util_logs
|
||||||
base["service"],
|
|
||||||
base["identifier"],
|
logger = util_logs.get_logger("compose")
|
||||||
text=text,
|
log_prefix = (
|
||||||
attachments=[],
|
f"[ComposeSend] service={base['service']} identifier={base['identifier']}"
|
||||||
)
|
)
|
||||||
|
logger.info(f"{log_prefix} text_len={len(text)} attempting send")
|
||||||
|
|
||||||
|
# If runtime is out-of-process, enqueue command and return immediately (non-blocking).
|
||||||
|
# Expose command id for cancellation so the client can cancel or poll later.
|
||||||
|
runtime_client = transport.get_runtime_client(base["service"]) or None
|
||||||
|
logger.info(
|
||||||
|
f"{log_prefix} runtime_client={type(runtime_client).__name__ if runtime_client else 'None (queued)'}"
|
||||||
|
)
|
||||||
|
ts = None
|
||||||
|
command_id = None
|
||||||
|
if runtime_client is None:
|
||||||
|
logger.info(f"{log_prefix} enqueuing runtime command (out-of-process)")
|
||||||
|
command_id = transport.enqueue_runtime_command(
|
||||||
|
base["service"],
|
||||||
|
"send_message_raw",
|
||||||
|
{"recipient": base["identifier"], "text": text, "attachments": []},
|
||||||
|
)
|
||||||
|
logger.info(
|
||||||
|
f"{log_prefix} command_id={command_id} enqueued, returning immediately"
|
||||||
|
)
|
||||||
|
# attach command id to request so _response can include it in HX-Trigger
|
||||||
|
request._compose_command_id = command_id
|
||||||
|
# Do NOT wait here — return immediately so the UI doesn't block.
|
||||||
|
# Record a pending message locally so the thread shows the outgoing message.
|
||||||
|
ts = int(time.time() * 1000)
|
||||||
|
else:
|
||||||
|
# In-process runtime can perform the send synchronously and return a timestamp.
|
||||||
|
logger.info(f"{log_prefix} calling in-process send_message_raw (blocking)")
|
||||||
|
ts = async_to_sync(transport.send_message_raw)(
|
||||||
|
base["service"],
|
||||||
|
base["identifier"],
|
||||||
|
text=text,
|
||||||
|
attachments=[],
|
||||||
|
)
|
||||||
|
logger.info(f"{log_prefix} in-process send returned ts={ts}")
|
||||||
|
# For queued sends we set `ts` to a local timestamp; for in-process sends ts may be False.
|
||||||
if not ts:
|
if not ts:
|
||||||
return self._response(
|
return self._response(
|
||||||
request,
|
request,
|
||||||
@@ -3124,15 +3318,34 @@ class ComposeSend(LoginRequiredMixin, View):
|
|||||||
user=request.user,
|
user=request.user,
|
||||||
identifier=base["person_identifier"],
|
identifier=base["person_identifier"],
|
||||||
)
|
)
|
||||||
Message.objects.create(
|
logger.info(f"{log_prefix} session_id={session.id}")
|
||||||
|
# For in-process sends (Signal, etc), ts is a timestamp or True.
|
||||||
|
# For queued sends (WhatsApp/UR), ts is a local timestamp.
|
||||||
|
# Set delivered_ts only if we got a real timestamp OR if it's an in-process sync send.
|
||||||
|
msg_ts = int(ts) if str(ts).isdigit() else int(time.time() * 1000)
|
||||||
|
delivered_ts = msg_ts if runtime_client is not None else None
|
||||||
|
msg = Message.objects.create(
|
||||||
user=request.user,
|
user=request.user,
|
||||||
session=session,
|
session=session,
|
||||||
sender_uuid="",
|
sender_uuid="",
|
||||||
text=text,
|
text=text,
|
||||||
ts=int(ts) if str(ts).isdigit() else int(time.time() * 1000),
|
ts=msg_ts,
|
||||||
delivered_ts=int(ts) if str(ts).isdigit() else None,
|
delivered_ts=delivered_ts,
|
||||||
custom_author="USER",
|
custom_author="USER",
|
||||||
)
|
)
|
||||||
|
logger.info(
|
||||||
|
f"{log_prefix} created message id={msg.id} ts={msg_ts} delivered_ts={delivered_ts} custom_author=USER"
|
||||||
|
)
|
||||||
|
|
||||||
|
# If we enqueued, inform the client the message is queued and include command id.
|
||||||
|
if runtime_client is None:
|
||||||
|
return self._response(
|
||||||
|
request,
|
||||||
|
ok=True,
|
||||||
|
message="Message queued for sending.",
|
||||||
|
level="info",
|
||||||
|
panel_id=panel_id,
|
||||||
|
)
|
||||||
|
|
||||||
return self._response(
|
return self._response(
|
||||||
request,
|
request,
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ from mixins.views import ObjectCreate, ObjectDelete, ObjectUpdate
|
|||||||
|
|
||||||
from core.forms import GroupForm
|
from core.forms import GroupForm
|
||||||
from core.models import Group
|
from core.models import Group
|
||||||
from core.views.osint import OSINTListBase
|
|
||||||
from core.util import logs
|
from core.util import logs
|
||||||
|
from core.views.osint import OSINTListBase
|
||||||
|
|
||||||
log = logs.get_logger(__name__)
|
log = logs.get_logger(__name__)
|
||||||
|
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ from mixins.views import ObjectCreate, ObjectDelete, ObjectUpdate
|
|||||||
|
|
||||||
from core.forms import ManipulationForm
|
from core.forms import ManipulationForm
|
||||||
from core.models import Manipulation
|
from core.models import Manipulation
|
||||||
from core.views.osint import OSINTListBase
|
|
||||||
from core.util import logs
|
from core.util import logs
|
||||||
|
from core.views.osint import OSINTListBase
|
||||||
|
|
||||||
log = logs.get_logger(__name__)
|
log = logs.get_logger(__name__)
|
||||||
|
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ from django.urls import reverse
|
|||||||
from django.views import View
|
from django.views import View
|
||||||
from mixins.views import ObjectList
|
from mixins.views import ObjectList
|
||||||
|
|
||||||
from core.models import Group, Manipulation, Persona, Person
|
from core.models import Group, Manipulation, Person, Persona
|
||||||
|
|
||||||
|
|
||||||
def _context_type(request_type: str) -> str:
|
def _context_type(request_type: str) -> str:
|
||||||
@@ -82,9 +82,7 @@ def _url_with_query(base_url: str, query: dict[str, Any]) -> str:
|
|||||||
return f"{base_url}?{urlencode(params)}"
|
return f"{base_url}?{urlencode(params)}"
|
||||||
|
|
||||||
|
|
||||||
def _merge_query(
|
def _merge_query(current_query: dict[str, Any], **updates: Any) -> dict[str, Any]:
|
||||||
current_query: dict[str, Any], **updates: Any
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
merged = dict(current_query)
|
merged = dict(current_query)
|
||||||
for key, value in updates.items():
|
for key, value in updates.items():
|
||||||
if value is None or str(value).strip() == "":
|
if value is None or str(value).strip() == "":
|
||||||
@@ -695,9 +693,7 @@ class OSINTSearch(LoginRequiredMixin, View):
|
|||||||
per_page_default = 20
|
per_page_default = 20
|
||||||
per_page_max = 100
|
per_page_max = 100
|
||||||
|
|
||||||
def _field_options(
|
def _field_options(self, model_cls: type[models.Model]) -> list[dict[str, str]]:
|
||||||
self, model_cls: type[models.Model]
|
|
||||||
) -> list[dict[str, str]]:
|
|
||||||
options = []
|
options = []
|
||||||
for field in model_cls._meta.get_fields():
|
for field in model_cls._meta.get_fields():
|
||||||
# Skip reverse/accessor relations (e.g. ManyToManyRel) that are not
|
# Skip reverse/accessor relations (e.g. ManyToManyRel) that are not
|
||||||
@@ -768,16 +764,18 @@ class OSINTSearch(LoginRequiredMixin, View):
|
|||||||
if isinstance(field, models.ForeignKey):
|
if isinstance(field, models.ForeignKey):
|
||||||
related_text_field = _preferred_related_text_field(field.related_model)
|
related_text_field = _preferred_related_text_field(field.related_model)
|
||||||
if related_text_field:
|
if related_text_field:
|
||||||
return Q(
|
return (
|
||||||
**{f"{field_name}__{related_text_field}__icontains": query}
|
Q(**{f"{field_name}__{related_text_field}__icontains": query}),
|
||||||
), False
|
False,
|
||||||
|
)
|
||||||
return Q(**{f"{field_name}__id__icontains": query}), False
|
return Q(**{f"{field_name}__id__icontains": query}), False
|
||||||
if isinstance(field, models.ManyToManyField):
|
if isinstance(field, models.ManyToManyField):
|
||||||
related_text_field = _preferred_related_text_field(field.related_model)
|
related_text_field = _preferred_related_text_field(field.related_model)
|
||||||
if related_text_field:
|
if related_text_field:
|
||||||
return Q(
|
return (
|
||||||
**{f"{field_name}__{related_text_field}__icontains": query}
|
Q(**{f"{field_name}__{related_text_field}__icontains": query}),
|
||||||
), True
|
True,
|
||||||
|
)
|
||||||
return Q(**{f"{field_name}__id__icontains": query}), True
|
return Q(**{f"{field_name}__id__icontains": query}), True
|
||||||
return None, False
|
return None, False
|
||||||
|
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ from mixins.views import ObjectCreate, ObjectDelete, ObjectUpdate
|
|||||||
|
|
||||||
from core.forms import PersonForm
|
from core.forms import PersonForm
|
||||||
from core.models import Person
|
from core.models import Person
|
||||||
from core.views.osint import OSINTListBase
|
|
||||||
from core.util import logs
|
from core.util import logs
|
||||||
|
from core.views.osint import OSINTListBase
|
||||||
|
|
||||||
log = logs.get_logger(__name__)
|
log = logs.get_logger(__name__)
|
||||||
|
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ from mixins.views import ObjectCreate, ObjectDelete, ObjectUpdate
|
|||||||
|
|
||||||
from core.forms import PersonaForm
|
from core.forms import PersonaForm
|
||||||
from core.models import Persona
|
from core.models import Persona
|
||||||
from core.views.osint import OSINTListBase
|
|
||||||
from core.util import logs
|
from core.util import logs
|
||||||
|
from core.views.osint import OSINTListBase
|
||||||
|
|
||||||
log = logs.get_logger(__name__)
|
log = logs.get_logger(__name__)
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
|
from urllib.parse import urlencode
|
||||||
|
|
||||||
import orjson
|
import orjson
|
||||||
import requests
|
import requests
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.shortcuts import render
|
from django.shortcuts import render
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from urllib.parse import urlencode
|
|
||||||
from django.views import View
|
from django.views import View
|
||||||
from mixins.views import ObjectList, ObjectRead
|
from mixins.views import ObjectList, ObjectRead
|
||||||
|
|
||||||
|
|||||||
@@ -19,8 +19,8 @@ from core.models import (
|
|||||||
PatternMitigationPlan,
|
PatternMitigationPlan,
|
||||||
PatternMitigationRule,
|
PatternMitigationRule,
|
||||||
Person,
|
Person,
|
||||||
PersonIdentifier,
|
|
||||||
Persona,
|
Persona,
|
||||||
|
PersonIdentifier,
|
||||||
QueuedMessage,
|
QueuedMessage,
|
||||||
WorkspaceConversation,
|
WorkspaceConversation,
|
||||||
WorkspaceMetricSnapshot,
|
WorkspaceMetricSnapshot,
|
||||||
@@ -37,7 +37,9 @@ class SystemSettings(SuperUserRequiredMixin, View):
|
|||||||
"messages": Message.objects.filter(user=user).count(),
|
"messages": Message.objects.filter(user=user).count(),
|
||||||
"queued_messages": QueuedMessage.objects.filter(user=user).count(),
|
"queued_messages": QueuedMessage.objects.filter(user=user).count(),
|
||||||
"message_events": MessageEvent.objects.filter(user=user).count(),
|
"message_events": MessageEvent.objects.filter(user=user).count(),
|
||||||
"workspace_conversations": WorkspaceConversation.objects.filter(user=user).count(),
|
"workspace_conversations": WorkspaceConversation.objects.filter(
|
||||||
|
user=user
|
||||||
|
).count(),
|
||||||
"workspace_snapshots": WorkspaceMetricSnapshot.objects.filter(
|
"workspace_snapshots": WorkspaceMetricSnapshot.objects.filter(
|
||||||
conversation__user=user
|
conversation__user=user
|
||||||
).count(),
|
).count(),
|
||||||
@@ -57,7 +59,9 @@ class SystemSettings(SuperUserRequiredMixin, View):
|
|||||||
"mitigation_auto_settings": PatternMitigationAutoSettings.objects.filter(
|
"mitigation_auto_settings": PatternMitigationAutoSettings.objects.filter(
|
||||||
user=user
|
user=user
|
||||||
).count(),
|
).count(),
|
||||||
"mitigation_exports": PatternArtifactExport.objects.filter(user=user).count(),
|
"mitigation_exports": PatternArtifactExport.objects.filter(
|
||||||
|
user=user
|
||||||
|
).count(),
|
||||||
"osint_people": Person.objects.filter(user=user).count(),
|
"osint_people": Person.objects.filter(user=user).count(),
|
||||||
"osint_identifiers": PersonIdentifier.objects.filter(user=user).count(),
|
"osint_identifiers": PersonIdentifier.objects.filter(user=user).count(),
|
||||||
"osint_groups": Group.objects.filter(user=user).count(),
|
"osint_groups": Group.objects.filter(user=user).count(),
|
||||||
@@ -77,7 +81,9 @@ class SystemSettings(SuperUserRequiredMixin, View):
|
|||||||
deleted += AIResult.objects.filter(user=user).delete()[0]
|
deleted += AIResult.objects.filter(user=user).delete()[0]
|
||||||
deleted += AIRequest.objects.filter(user=user).delete()[0]
|
deleted += AIRequest.objects.filter(user=user).delete()[0]
|
||||||
deleted += MemoryItem.objects.filter(user=user).delete()[0]
|
deleted += MemoryItem.objects.filter(user=user).delete()[0]
|
||||||
deleted += WorkspaceMetricSnapshot.objects.filter(conversation__user=user).delete()[0]
|
deleted += WorkspaceMetricSnapshot.objects.filter(
|
||||||
|
conversation__user=user
|
||||||
|
).delete()[0]
|
||||||
deleted += MessageEvent.objects.filter(user=user).delete()[0]
|
deleted += MessageEvent.objects.filter(user=user).delete()[0]
|
||||||
deleted += Message.objects.filter(user=user).delete()[0]
|
deleted += Message.objects.filter(user=user).delete()[0]
|
||||||
deleted += QueuedMessage.objects.filter(user=user).delete()[0]
|
deleted += QueuedMessage.objects.filter(user=user).delete()[0]
|
||||||
|
|||||||
@@ -1,15 +1,16 @@
|
|||||||
|
import time
|
||||||
|
from urllib.parse import urlencode
|
||||||
|
|
||||||
from django.shortcuts import render
|
from django.shortcuts import render
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from urllib.parse import urlencode
|
|
||||||
from django.views import View
|
from django.views import View
|
||||||
from mixins.views import ObjectList, ObjectRead
|
from mixins.views import ObjectList, ObjectRead
|
||||||
|
|
||||||
from core.clients import transport
|
from core.clients import transport
|
||||||
from core.models import ChatSession, Message, PersonIdentifier
|
from core.models import ChatSession, Message, PersonIdentifier
|
||||||
|
from core.util import logs
|
||||||
from core.views.compose import _compose_urls, _service_icon_class
|
from core.views.compose import _compose_urls, _service_icon_class
|
||||||
from core.views.manage.permissions import SuperUserRequiredMixin
|
from core.views.manage.permissions import SuperUserRequiredMixin
|
||||||
from core.util import logs
|
|
||||||
import time
|
|
||||||
|
|
||||||
log = logs.get_logger("whatsapp_view")
|
log = logs.get_logger("whatsapp_view")
|
||||||
|
|
||||||
@@ -32,16 +33,13 @@ class WhatsApp(SuperUserRequiredMixin, View):
|
|||||||
)
|
)
|
||||||
|
|
||||||
def delete(self, request, *args, **kwargs):
|
def delete(self, request, *args, **kwargs):
|
||||||
account = (
|
account = str(request.GET.get("account") or "").strip() or next(
|
||||||
str(request.GET.get("account") or "").strip()
|
(
|
||||||
or next(
|
str(item or "").strip()
|
||||||
(
|
for item in transport.list_accounts("whatsapp")
|
||||||
str(item or "").strip()
|
if str(item or "").strip()
|
||||||
for item in transport.list_accounts("whatsapp")
|
),
|
||||||
if str(item or "").strip()
|
"",
|
||||||
),
|
|
||||||
"",
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
if account:
|
if account:
|
||||||
transport.unlink_account("whatsapp", account)
|
transport.unlink_account("whatsapp", account)
|
||||||
@@ -381,9 +379,7 @@ class WhatsAppAccountAdd(SuperUserRequiredMixin, ObjectRead):
|
|||||||
|
|
||||||
def _detail_context(self, kwargs, obj):
|
def _detail_context(self, kwargs, obj):
|
||||||
detail_url_args = {
|
detail_url_args = {
|
||||||
arg: kwargs[arg]
|
arg: kwargs[arg] for arg in self.detail_url_args if arg in kwargs
|
||||||
for arg in self.detail_url_args
|
|
||||||
if arg in kwargs
|
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
"object": obj,
|
"object": obj,
|
||||||
@@ -410,7 +406,9 @@ class WhatsAppAccountAdd(SuperUserRequiredMixin, ObjectRead):
|
|||||||
sqlite_scanned = int(state.get("history_sqlite_scanned") or 0)
|
sqlite_scanned = int(state.get("history_sqlite_scanned") or 0)
|
||||||
on_demand_requested = bool(state.get("history_on_demand_requested"))
|
on_demand_requested = bool(state.get("history_on_demand_requested"))
|
||||||
on_demand_error = str(state.get("history_on_demand_error") or "").strip() or "-"
|
on_demand_error = str(state.get("history_on_demand_error") or "").strip() or "-"
|
||||||
on_demand_anchor = str(state.get("history_on_demand_anchor") or "").strip() or "-"
|
on_demand_anchor = (
|
||||||
|
str(state.get("history_on_demand_anchor") or "").strip() or "-"
|
||||||
|
)
|
||||||
history_running = bool(state.get("history_sync_running"))
|
history_running = bool(state.get("history_sync_running"))
|
||||||
return [
|
return [
|
||||||
f"connected={bool(state.get('connected'))}",
|
f"connected={bool(state.get('connected'))}",
|
||||||
|
|||||||
@@ -147,8 +147,7 @@ INSIGHT_METRICS = {
|
|||||||
"group": "stability",
|
"group": "stability",
|
||||||
"history_field": "stability_score",
|
"history_field": "stability_score",
|
||||||
"calculation": (
|
"calculation": (
|
||||||
"0.35*reciprocity + 0.25*continuity + 0.20*response + "
|
"0.35*reciprocity + 0.25*continuity + 0.20*response + " "0.20*volatility."
|
||||||
"0.20*volatility."
|
|
||||||
),
|
),
|
||||||
"psychology": (
|
"psychology": (
|
||||||
"Higher values suggest consistent mutual engagement patterns; falling "
|
"Higher values suggest consistent mutual engagement patterns; falling "
|
||||||
@@ -176,9 +175,7 @@ INSIGHT_METRICS = {
|
|||||||
"100 * min(1, distinct_sample_days / span_days). Higher means steadier "
|
"100 * min(1, distinct_sample_days / span_days). Higher means steadier "
|
||||||
"day-to-day continuity."
|
"day-to-day continuity."
|
||||||
),
|
),
|
||||||
"psychology": (
|
"psychology": ("Drops can signal communication becoming episodic or reactive."),
|
||||||
"Drops can signal communication becoming episodic or reactive."
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
"response_score": {
|
"response_score": {
|
||||||
"title": "Response Component",
|
"title": "Response Component",
|
||||||
@@ -232,8 +229,7 @@ INSIGHT_METRICS = {
|
|||||||
"history_field": "stability_sample_days",
|
"history_field": "stability_sample_days",
|
||||||
"calculation": "Count of distinct calendar days represented in the sample.",
|
"calculation": "Count of distinct calendar days represented in the sample.",
|
||||||
"psychology": (
|
"psychology": (
|
||||||
"Coverage across days better captures rhythm, not just intensity "
|
"Coverage across days better captures rhythm, not just intensity " "bursts."
|
||||||
"bursts."
|
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
"stability_computed": {
|
"stability_computed": {
|
||||||
@@ -250,9 +246,7 @@ INSIGHT_METRICS = {
|
|||||||
"title": "Commit In",
|
"title": "Commit In",
|
||||||
"group": "commitment",
|
"group": "commitment",
|
||||||
"history_field": "commitment_inbound_score",
|
"history_field": "commitment_inbound_score",
|
||||||
"calculation": (
|
"calculation": ("0.60*inbound_response_score + 0.40*inbound_balance_score."),
|
||||||
"0.60*inbound_response_score + 0.40*inbound_balance_score."
|
|
||||||
),
|
|
||||||
"psychology": (
|
"psychology": (
|
||||||
"Estimates counterpart follow-through and reciprocity toward the user."
|
"Estimates counterpart follow-through and reciprocity toward the user."
|
||||||
),
|
),
|
||||||
@@ -261,9 +255,7 @@ INSIGHT_METRICS = {
|
|||||||
"title": "Commit Out",
|
"title": "Commit Out",
|
||||||
"group": "commitment",
|
"group": "commitment",
|
||||||
"history_field": "commitment_outbound_score",
|
"history_field": "commitment_outbound_score",
|
||||||
"calculation": (
|
"calculation": ("0.60*outbound_response_score + 0.40*outbound_balance_score."),
|
||||||
"0.60*outbound_response_score + 0.40*outbound_balance_score."
|
|
||||||
),
|
|
||||||
"psychology": (
|
"psychology": (
|
||||||
"Estimates user follow-through and consistency toward the counterpart."
|
"Estimates user follow-through and consistency toward the counterpart."
|
||||||
),
|
),
|
||||||
@@ -931,16 +923,22 @@ def _metric_psychological_read(metric_slug, conversation):
|
|||||||
if score is None:
|
if score is None:
|
||||||
return "Calibrating: collect more interaction data before interpreting."
|
return "Calibrating: collect more interaction data before interpreting."
|
||||||
if score >= 70:
|
if score >= 70:
|
||||||
return "Pattern suggests low relational friction and resilient repair cycles."
|
return (
|
||||||
|
"Pattern suggests low relational friction and resilient repair cycles."
|
||||||
|
)
|
||||||
if score >= 50:
|
if score >= 50:
|
||||||
return "Pattern suggests moderate strain; monitor for repeated escalation loops."
|
return "Pattern suggests moderate strain; monitor for repeated escalation loops."
|
||||||
return "Pattern suggests high friction risk; prioritise safety and repair pacing."
|
return (
|
||||||
|
"Pattern suggests high friction risk; prioritise safety and repair pacing."
|
||||||
|
)
|
||||||
if metric_slug == "stability_confidence":
|
if metric_slug == "stability_confidence":
|
||||||
conf = conversation.stability_confidence or 0.0
|
conf = conversation.stability_confidence or 0.0
|
||||||
if conf < 0.25:
|
if conf < 0.25:
|
||||||
return "Low certainty: treat this as a weak signal, not a conclusion."
|
return "Low certainty: treat this as a weak signal, not a conclusion."
|
||||||
if conf < 0.6:
|
if conf < 0.6:
|
||||||
return "Moderate certainty: useful directional cue, still context-dependent."
|
return (
|
||||||
|
"Moderate certainty: useful directional cue, still context-dependent."
|
||||||
|
)
|
||||||
return "High certainty: trend interpretation is likely reliable."
|
return "High certainty: trend interpretation is likely reliable."
|
||||||
if metric_slug in {"commitment_inbound", "commitment_outbound"}:
|
if metric_slug in {"commitment_inbound", "commitment_outbound"}:
|
||||||
inbound = conversation.commitment_inbound_score
|
inbound = conversation.commitment_inbound_score
|
||||||
@@ -3119,7 +3117,7 @@ def _ai_detect_violations(user, plan, person, recent_rows, metric_context=None):
|
|||||||
"clarification": "proactive correction mapped to an artifact",
|
"clarification": "proactive correction mapped to an artifact",
|
||||||
"severity": "low|medium|high",
|
"severity": "low|medium|high",
|
||||||
}
|
}
|
||||||
]
|
],
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
prompt = [
|
prompt = [
|
||||||
@@ -3673,7 +3671,9 @@ class AIWorkspaceInformation(LoginRequiredMixin, View):
|
|||||||
latest_snapshot = conversation.metric_snapshots.first()
|
latest_snapshot = conversation.metric_snapshots.first()
|
||||||
directionality = _commitment_directionality_payload(conversation)
|
directionality = _commitment_directionality_payload(conversation)
|
||||||
commitment_graph_cards = [
|
commitment_graph_cards = [
|
||||||
card for card in _all_graph_payload(conversation) if card["group"] == "commitment"
|
card
|
||||||
|
for card in _all_graph_payload(conversation)
|
||||||
|
if card["group"] == "commitment"
|
||||||
]
|
]
|
||||||
|
|
||||||
graph_refs = []
|
graph_refs = []
|
||||||
|
|||||||
@@ -271,47 +271,10 @@ services:
|
|||||||
# memory: 0.25G
|
# memory: 0.25G
|
||||||
#network_mode: host
|
#network_mode: host
|
||||||
|
|
||||||
# Optional watcher service to restart the runtime router (UR) when core code changes.
|
# Watchers disabled - use manual restart for ur and scheduling services
|
||||||
# This runs the `docker/watch_and_restart.py` script inside the same image and
|
# uWSGI auto-reload is enabled in uwsgi.ini for core code changes
|
||||||
# will restart the `ur_gia` container when files under `/code/core` change.
|
# To restart ur after code changes: docker-compose restart ur
|
||||||
watch_ur:
|
# To restart scheduling after code changes: docker-compose restart scheduling
|
||||||
image: xf/gia:prod
|
|
||||||
container_name: watch_ur_gia
|
|
||||||
build:
|
|
||||||
context: .
|
|
||||||
args:
|
|
||||||
OPERATION: ${OPERATION}
|
|
||||||
command: sh -c '. /venv/bin/activate && python docker/watch_and_restart.py'
|
|
||||||
volumes:
|
|
||||||
- ${REPO_DIR}:/code
|
|
||||||
- ${REPO_DIR}/docker/uwsgi.ini:/conf/uwsgi.ini
|
|
||||||
- ${APP_DATABASE_FILE}:/conf/db.sqlite3
|
|
||||||
- type: bind
|
|
||||||
source: /code/vrun
|
|
||||||
target: /var/run
|
|
||||||
environment:
|
|
||||||
WATCH_PATHS: "/code/core"
|
|
||||||
TARGET_CONTAINER: "ur_gia"
|
|
||||||
|
|
||||||
# Optional watcher service to restart the scheduling process when app code changes.
|
|
||||||
watch_scheduling:
|
|
||||||
image: xf/gia:prod
|
|
||||||
container_name: watch_scheduling_gia
|
|
||||||
build:
|
|
||||||
context: .
|
|
||||||
args:
|
|
||||||
OPERATION: ${OPERATION}
|
|
||||||
command: sh -c '. /venv/bin/activate && python docker/watch_and_restart.py'
|
|
||||||
volumes:
|
|
||||||
- ${REPO_DIR}:/code
|
|
||||||
- ${REPO_DIR}/docker/uwsgi.ini:/conf/uwsgi.ini
|
|
||||||
- ${APP_DATABASE_FILE}:/conf/db.sqlite3
|
|
||||||
- type: bind
|
|
||||||
source: /code/vrun
|
|
||||||
target: /var/run
|
|
||||||
environment:
|
|
||||||
WATCH_PATHS: "/code/app"
|
|
||||||
TARGET_CONTAINER: "scheduling_gia"
|
|
||||||
|
|
||||||
redis:
|
redis:
|
||||||
image: redis
|
image: redis
|
||||||
|
|||||||
@@ -24,12 +24,13 @@ management command), you can "touch" any file under the watched path:
|
|||||||
The watcher ignores `__pycache__`, `.pyc` files and `.git` paths.
|
The watcher ignores `__pycache__`, `.pyc` files and `.git` paths.
|
||||||
"""
|
"""
|
||||||
import os
|
import os
|
||||||
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
import time
|
import time
|
||||||
import subprocess
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from watchdog.observers import Observer
|
|
||||||
from watchdog.events import FileSystemEventHandler
|
from watchdog.events import FileSystemEventHandler
|
||||||
|
from watchdog.observers import Observer
|
||||||
|
|
||||||
|
|
||||||
class ChangeHandler(FileSystemEventHandler):
|
class ChangeHandler(FileSystemEventHandler):
|
||||||
@@ -48,7 +49,7 @@ class ChangeHandler(FileSystemEventHandler):
|
|||||||
|
|
||||||
def _check_and_restart(self, path):
|
def _check_and_restart(self, path):
|
||||||
# Ignore pycache and compiled files
|
# Ignore pycache and compiled files
|
||||||
if '__pycache__' in path or '.pyc' in path or '.git' in path:
|
if "__pycache__" in path or ".pyc" in path or ".git" in path:
|
||||||
return
|
return
|
||||||
|
|
||||||
now = time.time()
|
now = time.time()
|
||||||
@@ -62,13 +63,16 @@ class ChangeHandler(FileSystemEventHandler):
|
|||||||
|
|
||||||
def _restart_ur(self):
|
def _restart_ur(self):
|
||||||
# Determine target container from environment (default `ur_gia`)
|
# Determine target container from environment (default `ur_gia`)
|
||||||
target = os.environ.get('TARGET_CONTAINER', 'ur_gia')
|
target = os.environ.get("TARGET_CONTAINER", "ur_gia")
|
||||||
print(f'[{time.strftime("%H:%M:%S")}] Restarting {target}...', flush=True)
|
print(f'[{time.strftime("%H:%M:%S")}] Restarting {target}...', flush=True)
|
||||||
# Try podman first (preferred in this setup), then docker
|
# Try podman first (preferred in this setup), then docker
|
||||||
cmd = f"podman restart {target} 2>/dev/null || docker restart {target} 2>/dev/null"
|
cmd = f"podman restart {target} 2>/dev/null || docker restart {target} 2>/dev/null"
|
||||||
result = subprocess.run(cmd, shell=True, capture_output=True)
|
result = subprocess.run(cmd, shell=True, capture_output=True)
|
||||||
if result.returncode == 0:
|
if result.returncode == 0:
|
||||||
print(f'[{time.strftime("%H:%M:%S")}] {target} restarted successfully', flush=True)
|
print(
|
||||||
|
f'[{time.strftime("%H:%M:%S")}] {target} restarted successfully',
|
||||||
|
flush=True,
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
print(f'[{time.strftime("%H:%M:%S")}] {target} restart failed', flush=True)
|
print(f'[{time.strftime("%H:%M:%S")}] {target} restart failed', flush=True)
|
||||||
time.sleep(1)
|
time.sleep(1)
|
||||||
@@ -80,17 +84,20 @@ def main():
|
|||||||
|
|
||||||
# Allow overriding watched paths via environment variable `WATCH_PATHS`.
|
# Allow overriding watched paths via environment variable `WATCH_PATHS`.
|
||||||
# Default is `/code/core,/code/app` but you can set e.g. `WATCH_PATHS=/code/core`
|
# Default is `/code/core,/code/app` but you can set e.g. `WATCH_PATHS=/code/core`
|
||||||
watch_paths_env = os.environ.get('WATCH_PATHS', '/code/core,/code/app')
|
watch_paths_env = os.environ.get("WATCH_PATHS", "/code/core,/code/app")
|
||||||
watch_paths = [p.strip() for p in watch_paths_env.split(',') if p.strip()]
|
watch_paths = [p.strip() for p in watch_paths_env.split(",") if p.strip()]
|
||||||
for path in watch_paths:
|
for path in watch_paths:
|
||||||
if os.path.exists(path):
|
if os.path.exists(path):
|
||||||
observer.schedule(handler, path, recursive=True)
|
observer.schedule(handler, path, recursive=True)
|
||||||
print(f'Watching: {path}', flush=True)
|
print(f"Watching: {path}", flush=True)
|
||||||
else:
|
else:
|
||||||
print(f'Not found (will not watch): {path}', flush=True)
|
print(f"Not found (will not watch): {path}", flush=True)
|
||||||
|
|
||||||
observer.start()
|
observer.start()
|
||||||
print(f'[{time.strftime("%H:%M:%S")}] File watcher started. Monitoring for changes...', flush=True)
|
print(
|
||||||
|
f'[{time.strftime("%H:%M:%S")}] File watcher started. Monitoring for changes...',
|
||||||
|
flush=True,
|
||||||
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
while True:
|
while True:
|
||||||
@@ -100,5 +107,5 @@ def main():
|
|||||||
observer.join()
|
observer.join()
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == "__main__":
|
||||||
main()
|
main()
|
||||||
|
|||||||
@@ -15,9 +15,9 @@ Touching a file under the watched path will trigger a restart of the target
|
|||||||
container; e.g. `touch /code/core/__restart__` will cause the watcher to act.
|
container; e.g. `touch /code/core/__restart__` will cause the watcher to act.
|
||||||
"""
|
"""
|
||||||
import os
|
import os
|
||||||
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
import time
|
import time
|
||||||
import subprocess
|
|
||||||
|
|
||||||
|
|
||||||
def get_mtime(path):
|
def get_mtime(path):
|
||||||
@@ -25,9 +25,9 @@ def get_mtime(path):
|
|||||||
max_mtime = 0
|
max_mtime = 0
|
||||||
for root, dirs, files in os.walk(path):
|
for root, dirs, files in os.walk(path):
|
||||||
# Skip pycache and hidden dirs
|
# Skip pycache and hidden dirs
|
||||||
dirs[:] = [d for d in dirs if not d.startswith('.') and d != '__pycache__']
|
dirs[:] = [d for d in dirs if not d.startswith(".") and d != "__pycache__"]
|
||||||
for file in files:
|
for file in files:
|
||||||
if file.endswith(('.pyc', '.pyo')):
|
if file.endswith((".pyc", ".pyo")):
|
||||||
continue
|
continue
|
||||||
try:
|
try:
|
||||||
mtime = os.path.getmtime(os.path.join(root, file))
|
mtime = os.path.getmtime(os.path.join(root, file))
|
||||||
@@ -39,9 +39,9 @@ def get_mtime(path):
|
|||||||
|
|
||||||
def restart_ur():
|
def restart_ur():
|
||||||
"""Restart target container (defaults to `ur_gia`)."""
|
"""Restart target container (defaults to `ur_gia`)."""
|
||||||
target = os.environ.get('TARGET_CONTAINER', 'ur_gia')
|
target = os.environ.get("TARGET_CONTAINER", "ur_gia")
|
||||||
print(f'[{time.strftime("%H:%M:%S")}] Restarting {target}...', flush=True)
|
print(f'[{time.strftime("%H:%M:%S")}] Restarting {target}...', flush=True)
|
||||||
cmd = f'podman restart {target} 2>/dev/null || docker restart {target} 2>/dev/null'
|
cmd = f"podman restart {target} 2>/dev/null || docker restart {target} 2>/dev/null"
|
||||||
result = subprocess.run(cmd, shell=True, capture_output=True)
|
result = subprocess.run(cmd, shell=True, capture_output=True)
|
||||||
if result.returncode == 0:
|
if result.returncode == 0:
|
||||||
print(f'[{time.strftime("%H:%M:%S")}] {target} restarted', flush=True)
|
print(f'[{time.strftime("%H:%M:%S")}] {target} restarted', flush=True)
|
||||||
@@ -53,16 +53,16 @@ def main():
|
|||||||
# In the container the repository is mounted at /code
|
# In the container the repository is mounted at /code
|
||||||
# Allow overriding watched paths via environment variable `WATCH_PATHS`.
|
# Allow overriding watched paths via environment variable `WATCH_PATHS`.
|
||||||
# Default is `/code/core,/code/app`.
|
# Default is `/code/core,/code/app`.
|
||||||
paths_env = os.environ.get('WATCH_PATHS', '/code/core,/code/app')
|
paths_env = os.environ.get("WATCH_PATHS", "/code/core,/code/app")
|
||||||
paths = [p.strip() for p in paths_env.split(',') if p.strip()]
|
paths = [p.strip() for p in paths_env.split(",") if p.strip()]
|
||||||
last_mtimes = {}
|
last_mtimes = {}
|
||||||
|
|
||||||
for path in paths:
|
for path in paths:
|
||||||
if os.path.exists(path):
|
if os.path.exists(path):
|
||||||
print(f'Watching: {path}', flush=True)
|
print(f"Watching: {path}", flush=True)
|
||||||
last_mtimes[path] = get_mtime(path)
|
last_mtimes[path] = get_mtime(path)
|
||||||
else:
|
else:
|
||||||
print(f'Not found: {path}', flush=True)
|
print(f"Not found: {path}", flush=True)
|
||||||
|
|
||||||
print(f'[{time.strftime("%H:%M:%S")}] Watcher started', flush=True)
|
print(f'[{time.strftime("%H:%M:%S")}] Watcher started', flush=True)
|
||||||
restart_debounce = 0
|
restart_debounce = 0
|
||||||
@@ -77,15 +77,17 @@ def main():
|
|||||||
continue
|
continue
|
||||||
current_mtime = get_mtime(path)
|
current_mtime = get_mtime(path)
|
||||||
if current_mtime > last_mtimes.get(path, 0):
|
if current_mtime > last_mtimes.get(path, 0):
|
||||||
print(f'[{time.strftime("%H:%M:%S")}] Changes in {path}', flush=True)
|
print(
|
||||||
|
f'[{time.strftime("%H:%M:%S")}] Changes in {path}', flush=True
|
||||||
|
)
|
||||||
last_mtimes[path] = current_mtime
|
last_mtimes[path] = current_mtime
|
||||||
if restart_debounce <= 0:
|
if restart_debounce <= 0:
|
||||||
restart_ur()
|
restart_ur()
|
||||||
restart_debounce = 5 # Don't restart more than every 5s
|
restart_debounce = 5 # Don't restart more than every 5s
|
||||||
except KeyboardInterrupt:
|
except KeyboardInterrupt:
|
||||||
print('Watcher stopped', flush=True)
|
print("Watcher stopped", flush=True)
|
||||||
sys.exit(0)
|
sys.exit(0)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == "__main__":
|
||||||
main()
|
main()
|
||||||
|
|||||||
Reference in New Issue
Block a user