Tightly integrate WhatsApp selectors into existing UIs
This commit is contained in:
@@ -97,6 +97,12 @@ MIDDLEWARE = [
|
||||
|
||||
ROOT_URLCONF = "app.urls"
|
||||
ASGI_APPLICATION = "app.asgi.application"
|
||||
COMPOSE_WS_ENABLED = os.environ.get("COMPOSE_WS_ENABLED", "false").lower() in {
|
||||
"1",
|
||||
"true",
|
||||
"yes",
|
||||
"on",
|
||||
}
|
||||
|
||||
TEMPLATES = [
|
||||
{
|
||||
|
||||
@@ -184,6 +184,11 @@ urlpatterns = [
|
||||
compose.ComposeThread.as_view(),
|
||||
name="compose_thread",
|
||||
),
|
||||
path(
|
||||
"compose/history-sync/",
|
||||
compose.ComposeHistorySync.as_view(),
|
||||
name="compose_history_sync",
|
||||
),
|
||||
path(
|
||||
"compose/media/blob/",
|
||||
compose.ComposeMediaBlob.as_view(),
|
||||
|
||||
@@ -22,11 +22,6 @@ if _signal_http_url:
|
||||
parsed = urlparse(
|
||||
_signal_http_url if "://" in _signal_http_url else f"http://{_signal_http_url}"
|
||||
)
|
||||
configured_host = (parsed.hostname or "").strip().lower()
|
||||
runtime = os.getenv("container", "").strip().lower()
|
||||
if configured_host == "signal" and runtime == "podman":
|
||||
SIGNAL_HOST = "127.0.0.1"
|
||||
else:
|
||||
SIGNAL_HOST = parsed.hostname or "signal"
|
||||
SIGNAL_PORT = parsed.port or 8080
|
||||
else:
|
||||
@@ -276,8 +271,24 @@ class HandleMessage(Command):
|
||||
envelope_source_uuid = envelope.get("sourceUuid")
|
||||
envelope_source_number = envelope.get("sourceNumber")
|
||||
envelope_source = envelope.get("source")
|
||||
destination_number = (
|
||||
raw.get("envelope", {})
|
||||
.get("syncMessage", {})
|
||||
.get("sentMessage", {})
|
||||
.get("destination")
|
||||
)
|
||||
|
||||
primary_identifier = dest if is_from_bot else source_uuid
|
||||
if is_from_bot:
|
||||
# Outbound events must route only by destination identity.
|
||||
# Including the bot's own UUID/number leaks messages across people
|
||||
# if "self" identifiers are linked anywhere.
|
||||
identifier_candidates = _identifier_candidates(
|
||||
dest,
|
||||
destination_number,
|
||||
primary_identifier,
|
||||
)
|
||||
else:
|
||||
identifier_candidates = _identifier_candidates(
|
||||
primary_identifier,
|
||||
source_uuid,
|
||||
|
||||
@@ -19,6 +19,8 @@ from core.util import logs
|
||||
log = logs.get_logger("transport")
|
||||
|
||||
_RUNTIME_STATE_TTL = 60 * 60 * 24
|
||||
_RUNTIME_COMMANDS_TTL = 60 * 15
|
||||
_RUNTIME_COMMAND_RESULT_TTL = 60
|
||||
_RUNTIME_CLIENTS: dict[str, Any] = {}
|
||||
|
||||
|
||||
@@ -30,6 +32,14 @@ def _runtime_key(service: str) -> str:
|
||||
return f"gia:service:runtime:{_service_key(service)}"
|
||||
|
||||
|
||||
def _runtime_commands_key(service: str) -> str:
|
||||
return f"gia:service:commands:{_service_key(service)}"
|
||||
|
||||
|
||||
def _runtime_command_result_key(service: str, command_id: str) -> str:
|
||||
return f"gia:service:command-result:{_service_key(service)}:{command_id}"
|
||||
|
||||
|
||||
def _gateway_base(service: str) -> str:
|
||||
key = f"{service.upper()}_HTTP_URL"
|
||||
default = f"http://{service}:8080"
|
||||
@@ -78,6 +88,59 @@ def update_runtime_state(service: str, **updates):
|
||||
return state
|
||||
|
||||
|
||||
def enqueue_runtime_command(service: str, action: str, payload: dict | None = None) -> str:
|
||||
service_key = _service_key(service)
|
||||
command_id = secrets.token_hex(12)
|
||||
command = {
|
||||
"id": command_id,
|
||||
"action": str(action or "").strip(),
|
||||
"payload": dict(payload or {}),
|
||||
"created_at": int(time.time()),
|
||||
}
|
||||
key = _runtime_commands_key(service_key)
|
||||
queued = list(cache.get(key) or [])
|
||||
queued.append(command)
|
||||
# Keep queue bounded to avoid unbounded growth.
|
||||
if len(queued) > 200:
|
||||
queued = queued[-200:]
|
||||
cache.set(key, queued, timeout=_RUNTIME_COMMANDS_TTL)
|
||||
return command_id
|
||||
|
||||
|
||||
def pop_runtime_command(service: str) -> dict[str, Any] | None:
|
||||
service_key = _service_key(service)
|
||||
key = _runtime_commands_key(service_key)
|
||||
queued = list(cache.get(key) or [])
|
||||
if not queued:
|
||||
return None
|
||||
command = dict(queued.pop(0) or {})
|
||||
cache.set(key, queued, timeout=_RUNTIME_COMMANDS_TTL)
|
||||
return command
|
||||
|
||||
|
||||
def set_runtime_command_result(service: str, command_id: str, result: dict | None = None):
|
||||
service_key = _service_key(service)
|
||||
result_key = _runtime_command_result_key(service_key, command_id)
|
||||
payload = dict(result or {})
|
||||
payload.setdefault("completed_at", int(time.time()))
|
||||
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):
|
||||
service_key = _service_key(service)
|
||||
result_key = _runtime_command_result_key(service_key, command_id)
|
||||
deadline = time.monotonic() + max(0.1, float(timeout or 0.0))
|
||||
while time.monotonic() < deadline:
|
||||
payload = cache.get(result_key)
|
||||
if payload is not None:
|
||||
cache.delete(result_key)
|
||||
if isinstance(payload, dict):
|
||||
return dict(payload)
|
||||
return {}
|
||||
await asyncio.sleep(0.2)
|
||||
return None
|
||||
|
||||
|
||||
def list_accounts(service: str):
|
||||
"""
|
||||
Return account identifiers for service UI list.
|
||||
@@ -365,7 +428,37 @@ async def send_message_raw(service: str, recipient: str, text=None, attachments=
|
||||
return runtime_result
|
||||
except Exception as exc:
|
||||
log.warning("%s runtime send failed: %s", service_key, exc)
|
||||
log.warning("whatsapp send skipped: runtime is unavailable or not paired")
|
||||
# Web/UI process cannot access UR in-process runtime client directly.
|
||||
# Hand off send to UR via shared cache command queue.
|
||||
command_attachments = []
|
||||
for att in attachments or []:
|
||||
row = dict(att or {})
|
||||
# Keep payload cache-friendly and avoid embedding raw bytes.
|
||||
for key in ("content",):
|
||||
row.pop(key, None)
|
||||
command_attachments.append(row)
|
||||
command_id = enqueue_runtime_command(
|
||||
service_key,
|
||||
"send_message_raw",
|
||||
{
|
||||
"recipient": recipient,
|
||||
"text": text or "",
|
||||
"attachments": command_attachments,
|
||||
},
|
||||
)
|
||||
command_result = await wait_runtime_command_result(
|
||||
service_key,
|
||||
command_id,
|
||||
timeout=20.0,
|
||||
)
|
||||
if isinstance(command_result, dict):
|
||||
if command_result.get("ok"):
|
||||
ts = _parse_timestamp(command_result)
|
||||
return ts if ts else True
|
||||
err = str(command_result.get("error") or "").strip()
|
||||
log.warning("whatsapp queued send failed: %s", err or "unknown")
|
||||
return False
|
||||
log.warning("whatsapp queued send timed out waiting for runtime result")
|
||||
return False
|
||||
|
||||
if service_key == "instagram":
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -576,6 +576,67 @@
|
||||
window.giaWindowAnchor = null;
|
||||
};
|
||||
|
||||
window.giaEnableFloatingWindowInteractions = function (windowEl) {
|
||||
if (!windowEl || windowEl.dataset.giaWindowInteractive === "1") {
|
||||
return;
|
||||
}
|
||||
windowEl.dataset.giaWindowInteractive = "1";
|
||||
|
||||
// Disable magnet-block global drag so text inputs remain editable.
|
||||
windowEl.setAttribute("unmovable", "");
|
||||
|
||||
const heading = windowEl.querySelector(".panel-heading");
|
||||
if (!heading) {
|
||||
return;
|
||||
}
|
||||
|
||||
let dragging = false;
|
||||
let startX = 0;
|
||||
let startY = 0;
|
||||
let startLeft = 0;
|
||||
let startTop = 0;
|
||||
|
||||
const onMove = function (event) {
|
||||
if (!dragging) {
|
||||
return;
|
||||
}
|
||||
const deltaX = event.clientX - startX;
|
||||
const deltaY = event.clientY - startY;
|
||||
windowEl.style.left = (startLeft + deltaX) + "px";
|
||||
windowEl.style.top = (startTop + deltaY) + "px";
|
||||
windowEl.style.right = "auto";
|
||||
windowEl.style.bottom = "auto";
|
||||
};
|
||||
|
||||
const stopDrag = function () {
|
||||
dragging = false;
|
||||
document.removeEventListener("pointermove", onMove);
|
||||
document.removeEventListener("pointerup", stopDrag);
|
||||
};
|
||||
|
||||
heading.addEventListener("pointerdown", function (event) {
|
||||
if (event.button !== 0) {
|
||||
return;
|
||||
}
|
||||
const interactive = event.target.closest(
|
||||
"button, a, input, textarea, select, label, .delete, .icon"
|
||||
);
|
||||
if (interactive) {
|
||||
return;
|
||||
}
|
||||
const rect = windowEl.getBoundingClientRect();
|
||||
windowEl.style.position = "fixed";
|
||||
startLeft = rect.left;
|
||||
startTop = rect.top;
|
||||
startX = event.clientX;
|
||||
startY = event.clientY;
|
||||
dragging = true;
|
||||
document.addEventListener("pointermove", onMove);
|
||||
document.addEventListener("pointerup", stopDrag);
|
||||
event.preventDefault();
|
||||
});
|
||||
};
|
||||
|
||||
document.addEventListener("click", function (event) {
|
||||
const trigger = event.target.closest(".js-widget-spawn-trigger");
|
||||
if (!trigger) {
|
||||
@@ -593,12 +654,13 @@
|
||||
window.giaEnableWidgetSpawnButtons(target);
|
||||
const targetId = (target && target.id) || "";
|
||||
if (targetId === "windows-here") {
|
||||
const floatingWindow = target.querySelector(".floating-window");
|
||||
if (floatingWindow) {
|
||||
const floatingWindows = target.querySelectorAll(".floating-window");
|
||||
floatingWindows.forEach(function (floatingWindow) {
|
||||
window.setTimeout(function () {
|
||||
window.giaPositionFloatingWindow(floatingWindow);
|
||||
window.giaEnableFloatingWindowInteractions(floatingWindow);
|
||||
}, 0);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -6,6 +6,9 @@
|
||||
{% block close_button %}
|
||||
{% include "mixins/partials/close-widget.html" %}
|
||||
{% endblock %}
|
||||
<span class="icon is-small mr-1">
|
||||
<i class="{{ widget_icon|default:'fa-solid fa-window-maximize' }}"></i>
|
||||
</span>
|
||||
<i
|
||||
class="fa-solid fa-arrows-minimize has-text-grey-light float-right"
|
||||
onclick="grid.compact();"></i>
|
||||
|
||||
@@ -30,12 +30,12 @@
|
||||
<div class="column is-5">
|
||||
<article class="box">
|
||||
<h2 class="title is-6">Create Or Link Identifier</h2>
|
||||
<form method="post">
|
||||
<form id="compose-contact-link-form" method="post">
|
||||
{% csrf_token %}
|
||||
<div class="field">
|
||||
<label class="label is-small">Service</label>
|
||||
<div class="select is-fullwidth">
|
||||
<select name="service" required>
|
||||
<select id="link-service" name="service" required>
|
||||
{% for key, label in service_choices %}
|
||||
<option value="{{ key }}" {% if key == prefill_service %}selected{% endif %}>
|
||||
{{ label }}
|
||||
@@ -47,13 +47,13 @@
|
||||
<div class="field">
|
||||
<label class="label is-small">Identifier</label>
|
||||
<div class="control">
|
||||
<input class="input" type="text" name="identifier" value="{{ prefill_identifier }}" required>
|
||||
<input id="link-identifier" class="input" type="text" name="identifier" value="{{ prefill_identifier }}" required>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="label is-small">Existing Person</label>
|
||||
<div class="select is-fullwidth">
|
||||
<select name="person_id">
|
||||
<select id="link-person-id" name="person_id">
|
||||
<option value="">- Select person -</option>
|
||||
{% for person in people %}
|
||||
<option value="{{ person.id }}">{{ person.name }}</option>
|
||||
@@ -64,7 +64,7 @@
|
||||
<div class="field">
|
||||
<label class="label is-small">Or Create Person</label>
|
||||
<div class="control">
|
||||
<input class="input" type="text" name="person_name" placeholder="New person name">
|
||||
<input id="link-person-name" class="input" type="text" name="person_name" placeholder="New person name">
|
||||
</div>
|
||||
</div>
|
||||
<button class="button is-link" type="submit">
|
||||
@@ -79,29 +79,114 @@
|
||||
<article class="box">
|
||||
<h2 class="title is-6">Discovered Contacts</h2>
|
||||
{% if candidates %}
|
||||
<div id="discovered-contacts-shell" class="osint-table-shell">
|
||||
<div class="field has-addons is-flex-wrap-wrap" style="margin-bottom: 0.6rem;">
|
||||
<div class="control is-expanded" style="min-width: 14rem;">
|
||||
<input
|
||||
id="discovered-search"
|
||||
class="input"
|
||||
type="text"
|
||||
placeholder="Search discovered contacts...">
|
||||
</div>
|
||||
<div class="control">
|
||||
<div class="select">
|
||||
<select id="discovered-source">
|
||||
<option value="">All Platforms</option>
|
||||
{% for key, label in service_choices %}
|
||||
<option value="{{ key }}">{{ label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="control">
|
||||
<button id="discovered-search-reset" class="button is-light" type="button">
|
||||
Reset
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="osint-results-meta">
|
||||
<div class="osint-results-meta-left">
|
||||
<div class="dropdown is-hoverable" id="discovered-columns-dropdown">
|
||||
<div class="dropdown-trigger">
|
||||
<button class="button is-small is-light" aria-haspopup="true" aria-controls="discovered-columns-menu">
|
||||
<span>Show/Hide Fields</span>
|
||||
<span class="icon is-small"><i class="fa-solid fa-angle-down"></i></span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="dropdown-menu" id="discovered-columns-menu" role="menu">
|
||||
<div class="dropdown-content">
|
||||
<a class="dropdown-item discovered-col-toggle" data-col-index="0" href="#" onclick="return false;">
|
||||
<span class="icon is-small"><i class="fa-solid fa-check"></i></span>
|
||||
<span>Person</span>
|
||||
<span class="is-size-7 has-text-grey ml-2">(linked_person)</span>
|
||||
</a>
|
||||
<a class="dropdown-item discovered-col-toggle" data-col-index="1" href="#" onclick="return false;">
|
||||
<span class="icon is-small"><i class="fa-solid fa-check"></i></span>
|
||||
<span>Name</span>
|
||||
<span class="is-size-7 has-text-grey ml-2">(detected_name)</span>
|
||||
</a>
|
||||
<a class="dropdown-item discovered-col-toggle" data-col-index="2" href="#" onclick="return false;">
|
||||
<span class="icon is-small"><i class="fa-solid fa-check"></i></span>
|
||||
<span>Service</span>
|
||||
<span class="is-size-7 has-text-grey ml-2">(service)</span>
|
||||
</a>
|
||||
<a class="dropdown-item discovered-col-toggle" data-col-index="3" href="#" onclick="return false;">
|
||||
<span class="icon is-small"><i class="fa-solid fa-check"></i></span>
|
||||
<span>Identifier</span>
|
||||
<span class="is-size-7 has-text-grey ml-2">(identifier)</span>
|
||||
</a>
|
||||
<a class="dropdown-item discovered-col-toggle" data-col-index="4" href="#" onclick="return false;">
|
||||
<span class="icon is-small"><i class="fa-solid fa-check"></i></span>
|
||||
<span>Suggest</span>
|
||||
<span class="is-size-7 has-text-grey ml-2">(suggestions)</span>
|
||||
</a>
|
||||
<a class="dropdown-item discovered-col-toggle" data-col-index="5" href="#" onclick="return false;">
|
||||
<span class="icon is-small"><i class="fa-solid fa-check"></i></span>
|
||||
<span>Status</span>
|
||||
<span class="is-size-7 has-text-grey ml-2">(linked)</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button class="button is-small is-light" type="button" disabled>
|
||||
<span class="icon is-small"><i class="fa-solid fa-database"></i></span>
|
||||
<span>Static</span>
|
||||
</button>
|
||||
</div>
|
||||
<p class="osint-results-count">
|
||||
fetched {{ candidates|length }} result{% if candidates|length != 1 %}s{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="table-container">
|
||||
<table class="table is-fullwidth is-hoverable is-striped">
|
||||
<table id="discovered-contacts-table" class="table is-fullwidth is-hoverable is-striped">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Person</th>
|
||||
<th>Detected Name</th>
|
||||
<th>Service</th>
|
||||
<th>Identifier</th>
|
||||
<th>Suggested Match</th>
|
||||
<th>Status</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="2" class="discovered-col-2">Service</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="5" class="discovered-col-5">Status</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for row in candidates %}
|
||||
<tr>
|
||||
<td>{{ row.linked_person_name|default:"-" }}</td>
|
||||
<td>{{ row.detected_name|default:"-" }}</td>
|
||||
<td>
|
||||
<tr
|
||||
data-service="{{ row.service }}"
|
||||
data-person="{{ row.linked_person_name|default:'-'|lower }}"
|
||||
data-detected="{{ row.detected_name|default:'-'|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 }}">
|
||||
<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="2" class="discovered-col-2">
|
||||
{{ row.service|title }}
|
||||
</td>
|
||||
<td><code>{{ row.identifier }}</code></td>
|
||||
<td>
|
||||
<td data-discovered-col="3" class="discovered-col-3"><code>{{ row.identifier }}</code></td>
|
||||
<td data-discovered-col="4" class="discovered-col-4">
|
||||
{% if not row.linked_person and row.suggestions %}
|
||||
<div class="buttons are-small">
|
||||
{% for suggestion in row.suggestions %}
|
||||
@@ -124,17 +209,34 @@
|
||||
<span class="has-text-grey">-</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<td data-discovered-col="5" class="discovered-col-5">
|
||||
{% if row.linked_person %}
|
||||
<span class="tag is-success is-light">linked</span>
|
||||
{% else %}
|
||||
<span class="tag is-warning is-light">unlinked</span>
|
||||
<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
|
||||
type="button"
|
||||
class="button is-small is-light js-contact-info"
|
||||
data-service="{{ row.service|title }}"
|
||||
data-identifier="{{ row.identifier }}"
|
||||
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>
|
||||
<span>Message</span>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -142,6 +244,7 @@
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="has-text-grey">No contacts discovered yet.</p>
|
||||
{% endif %}
|
||||
@@ -151,4 +254,248 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div id="discovered-contact-popover" class="compose-ai-popover is-hidden" aria-hidden="true">
|
||||
<div class="compose-ai-card is-active" style="min-width: 18rem;">
|
||||
<p class="compose-ai-title">Contact Details</p>
|
||||
<div class="compose-ai-content">
|
||||
<table class="table is-fullwidth is-striped is-size-7">
|
||||
<tbody>
|
||||
<tr><th>Service</th><td id="modal-service">-</td></tr>
|
||||
<tr><th>Identifier</th><td><code id="modal-identifier">-</code></td></tr>
|
||||
<tr><th>Name</th><td id="modal-detected">-</td></tr>
|
||||
<tr><th>Linked Person</th><td id="modal-person">-</td></tr>
|
||||
<tr><th>Status</th><td id="modal-status">-</td></tr>
|
||||
<tr><th>Suggested</th><td id="modal-suggested">-</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
const shell = document.getElementById("discovered-contacts-shell");
|
||||
const table = document.getElementById("discovered-contacts-table");
|
||||
if (!table || !shell) return;
|
||||
const rows = Array.from(table.querySelectorAll("tbody tr"));
|
||||
const searchInput = document.getElementById("discovered-search");
|
||||
const sourceSelect = document.getElementById("discovered-source");
|
||||
const resetBtn = document.getElementById("discovered-search-reset");
|
||||
const linkForm = document.getElementById("compose-contact-link-form");
|
||||
const linkService = document.getElementById("link-service");
|
||||
const linkIdentifier = document.getElementById("link-identifier");
|
||||
const linkPerson = document.getElementById("link-person-id");
|
||||
const linkPersonName = document.getElementById("link-person-name");
|
||||
const toggles = Array.from(shell.querySelectorAll(".discovered-col-toggle"));
|
||||
const popover = document.getElementById("discovered-contact-popover");
|
||||
let activeInfoBtn = null;
|
||||
const storageKey = "gia_discovered_contacts_hidden_cols_v1";
|
||||
let hidden = [];
|
||||
try {
|
||||
hidden = JSON.parse(localStorage.getItem(storageKey) || "[]");
|
||||
} catch (e) {
|
||||
hidden = [];
|
||||
}
|
||||
if (!Array.isArray(hidden)) hidden = [];
|
||||
hidden = hidden.map(String);
|
||||
|
||||
function applyFilters() {
|
||||
const query = String(searchInput?.value || "").trim().toLowerCase();
|
||||
const source = String(sourceSelect?.value || "").trim().toLowerCase();
|
||||
rows.forEach((row) => {
|
||||
const hay = String(row.dataset.search || "").toLowerCase();
|
||||
const rowSource = String(row.dataset.service || "").toLowerCase();
|
||||
const searchPass = !query || hay.includes(query);
|
||||
const sourcePass = !source || rowSource === source;
|
||||
row.style.display = searchPass && sourcePass ? "" : "none";
|
||||
});
|
||||
}
|
||||
|
||||
function applyColumns() {
|
||||
const hiddenSet = new Set(hidden);
|
||||
shell.querySelectorAll("[data-discovered-col]").forEach((node) => {
|
||||
const idx = String(node.getAttribute("data-discovered-col") || "");
|
||||
node.style.display = hiddenSet.has(idx) ? "none" : "";
|
||||
});
|
||||
toggles.forEach((toggle) => {
|
||||
const idx = String(toggle.getAttribute("data-col-index") || "");
|
||||
const isHidden = hiddenSet.has(idx);
|
||||
toggle.classList.toggle("is-hidden-col", isHidden);
|
||||
const icon = toggle.querySelector("i");
|
||||
if (icon) {
|
||||
icon.className = isHidden ? "fa-solid fa-xmark" : "fa-solid fa-check";
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function persistColumns() {
|
||||
try {
|
||||
localStorage.setItem(storageKey, JSON.stringify(hidden));
|
||||
} catch (e) {
|
||||
// Ignore storage failures.
|
||||
}
|
||||
}
|
||||
|
||||
function hidePopover() {
|
||||
if (!popover) return;
|
||||
popover.classList.add("is-hidden");
|
||||
popover.setAttribute("aria-hidden", "true");
|
||||
activeInfoBtn = null;
|
||||
}
|
||||
|
||||
function positionPopover(btn) {
|
||||
if (!popover || !btn) return;
|
||||
const rect = btn.getBoundingClientRect();
|
||||
const width = Math.min(360, 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);
|
||||
popover.style.left = left + "px";
|
||||
popover.style.top = top + "px";
|
||||
popover.style.width = width + "px";
|
||||
}
|
||||
|
||||
function openPopoverFromButton(btn) {
|
||||
document.getElementById("modal-service").textContent = btn.dataset.service || "-";
|
||||
document.getElementById("modal-identifier").textContent = btn.dataset.identifier || "-";
|
||||
document.getElementById("modal-detected").textContent = btn.dataset.detected || "-";
|
||||
document.getElementById("modal-person").textContent = btn.dataset.person || "-";
|
||||
document.getElementById("modal-status").textContent = btn.dataset.status || "-";
|
||||
document.getElementById("modal-suggested").textContent = btn.dataset.suggested || "-";
|
||||
positionPopover(btn);
|
||||
popover?.classList.remove("is-hidden");
|
||||
popover?.setAttribute("aria-hidden", "false");
|
||||
}
|
||||
|
||||
toggles.forEach((toggle) => {
|
||||
toggle.addEventListener("click", function () {
|
||||
const idx = String(toggle.getAttribute("data-col-index") || "");
|
||||
if (!idx) return;
|
||||
if (hidden.includes(idx)) {
|
||||
hidden = hidden.filter((item) => item !== idx);
|
||||
} else {
|
||||
hidden.push(idx);
|
||||
}
|
||||
persistColumns();
|
||||
applyColumns();
|
||||
});
|
||||
});
|
||||
|
||||
if (resetBtn) {
|
||||
resetBtn.addEventListener("click", function () {
|
||||
if (searchInput) searchInput.value = "";
|
||||
if (sourceSelect) sourceSelect.value = "";
|
||||
applyFilters();
|
||||
});
|
||||
}
|
||||
|
||||
if (searchInput) searchInput.addEventListener("input", applyFilters);
|
||||
if (sourceSelect) sourceSelect.addEventListener("change", applyFilters);
|
||||
|
||||
table.querySelectorAll(".js-contact-info").forEach((btn) => {
|
||||
btn.addEventListener("click", function () {
|
||||
if (activeInfoBtn === this && popover && !popover.classList.contains("is-hidden")) {
|
||||
hidePopover();
|
||||
return;
|
||||
}
|
||||
activeInfoBtn = this;
|
||||
openPopoverFromButton(this);
|
||||
});
|
||||
});
|
||||
|
||||
table.querySelectorAll(".js-unlinked-link").forEach((btn) => {
|
||||
btn.addEventListener("click", function () {
|
||||
if (linkService) linkService.value = String(this.dataset.service || "").toLowerCase();
|
||||
if (linkIdentifier) linkIdentifier.value = this.dataset.identifier || "";
|
||||
if (linkPerson) linkPerson.value = "";
|
||||
if (linkPersonName) linkPersonName.value = "";
|
||||
if (linkForm) {
|
||||
linkForm.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
}
|
||||
if (linkPerson) {
|
||||
linkPerson.focus();
|
||||
} else if (linkIdentifier) {
|
||||
linkIdentifier.focus();
|
||||
}
|
||||
});
|
||||
});
|
||||
document.addEventListener("click", function (ev) {
|
||||
if (!popover || popover.classList.contains("is-hidden")) return;
|
||||
if (popover.contains(ev.target)) return;
|
||||
if (activeInfoBtn && activeInfoBtn.contains(ev.target)) return;
|
||||
hidePopover();
|
||||
});
|
||||
window.addEventListener("resize", function () {
|
||||
if (activeInfoBtn && popover && !popover.classList.contains("is-hidden")) {
|
||||
positionPopover(activeInfoBtn);
|
||||
}
|
||||
});
|
||||
document.addEventListener("keydown", function (ev) {
|
||||
if (ev.key === "Escape") hidePopover();
|
||||
});
|
||||
|
||||
applyFilters();
|
||||
applyColumns();
|
||||
})();
|
||||
</script>
|
||||
|
||||
<style>
|
||||
#discovered-contacts-shell .osint-results-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.6rem;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 0.6rem;
|
||||
}
|
||||
#discovered-contacts-shell .osint-results-meta-left {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.42rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
#discovered-contacts-shell .osint-results-count {
|
||||
margin: 0;
|
||||
color: #6c757d;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
#discovered-contacts-shell .discovered-col-toggle .icon {
|
||||
color: #3273dc;
|
||||
}
|
||||
#discovered-contacts-shell .discovered-col-toggle.is-hidden-col .icon {
|
||||
color: #b5b5b5;
|
||||
}
|
||||
#discovered-contact-popover {
|
||||
position: fixed;
|
||||
z-index: 2000;
|
||||
transition: opacity 170ms ease, transform 170ms ease;
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
max-height: min(70vh, 30rem);
|
||||
overflow: auto;
|
||||
}
|
||||
#discovered-contact-popover.is-hidden {
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
transform: translateY(-6px) scale(0.985);
|
||||
}
|
||||
#discovered-contact-popover .compose-ai-card {
|
||||
border: 1px solid rgba(0, 0, 0, 0.16);
|
||||
border-radius: 10px;
|
||||
background: #fff;
|
||||
box-shadow: 0 18px 34px rgba(15, 23, 42, 0.16);
|
||||
padding: 0.55rem 0.65rem;
|
||||
}
|
||||
#discovered-contact-popover .compose-ai-title {
|
||||
font-size: 0.74rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: #4a4a4a;
|
||||
margin-bottom: 0.4rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
@@ -112,6 +112,7 @@
|
||||
hx-swap="innerHTML">
|
||||
<input type="hidden" id="draft-send-input-{{ person.id }}-{{ operation }}" name="draft_text" value="">
|
||||
<input type="hidden" id="draft-send-force-{{ person.id }}-{{ operation }}" name="force_send" value="0">
|
||||
<input type="hidden" name="target_identifier_id" value="{{ send_target_bundle.selected_id }}">
|
||||
<div class="field">
|
||||
<label class="label is-small">Draft Preview</label>
|
||||
<div class="control">
|
||||
|
||||
@@ -388,7 +388,23 @@
|
||||
<input type="hidden" name="active_tab" value="{{ active_tab|default:'engage' }}">
|
||||
<input type="hidden" id="engage-action-input-{{ person.id }}" name="action" value="preview">
|
||||
<input type="hidden" id="engage-force-send-{{ person.id }}" name="force_send" value="0">
|
||||
<input type="hidden" id="engage-target-input-{{ person.id }}" name="target_identifier_id" value="{{ engage_form.target_identifier_id }}">
|
||||
<div class="columns is-multiline" style="margin: 0 -0.3rem;">
|
||||
{% if send_target_bundle.options %}
|
||||
<div class="column is-12-mobile is-6-tablet" style="padding: 0.3rem;">
|
||||
<label class="label is-small" style="margin-bottom: 0.25rem;">Target Platform</label>
|
||||
<div class="select is-small is-fullwidth">
|
||||
<select id="engage-target-select-{{ person.id }}" onchange="giaEngageSetTarget('{{ person.id }}', this.value);">
|
||||
{% for option in send_target_bundle.options %}
|
||||
<option value="{{ option.id }}" {% if option.id == engage_form.target_identifier_id %}selected{% endif %}>
|
||||
{{ option.service_label }} · {{ option.identifier }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="column is-12-mobile is-6-tablet" style="padding: 0.3rem;">
|
||||
<label class="label is-small" style="margin-bottom: 0.25rem;">Source</label>
|
||||
<div class="select is-small is-fullwidth">
|
||||
@@ -897,6 +913,19 @@
|
||||
form.requestSubmit();
|
||||
};
|
||||
|
||||
window.giaEngageSetTarget = function(pid, targetId) {
|
||||
if (pid !== personId) return;
|
||||
const normalized = String(targetId || "").trim();
|
||||
const input = document.getElementById("engage-target-input-" + pid);
|
||||
if (input) {
|
||||
input.value = normalized;
|
||||
}
|
||||
const parentSelect = document.getElementById("ai-target-select-" + pid);
|
||||
if (parentSelect && normalized) {
|
||||
parentSelect.value = normalized;
|
||||
}
|
||||
};
|
||||
|
||||
window.giaEngageSelect = function(pid, kind, value, node) {
|
||||
if (pid !== personId) return;
|
||||
let inputId = "";
|
||||
@@ -922,6 +951,14 @@
|
||||
|
||||
window.giaMitigationShowTab(personId, "{{ active_tab|default:'plan_board' }}");
|
||||
resizeEditableTextareas(document.getElementById("mitigation-shell-" + personId));
|
||||
const parentTarget = document.getElementById("ai-target-select-" + personId);
|
||||
if (parentTarget) {
|
||||
window.giaEngageSetTarget(personId, parentTarget.value);
|
||||
}
|
||||
const localTarget = document.getElementById("engage-target-select-" + personId);
|
||||
if (localTarget) {
|
||||
window.giaEngageSetTarget(personId, localTarget.value);
|
||||
}
|
||||
window.giaEngageSyncSendOverride(personId);
|
||||
})();
|
||||
</script>
|
||||
|
||||
@@ -72,12 +72,13 @@
|
||||
{% endif %}
|
||||
{% if compose_page_url %}
|
||||
<div class="buttons are-small" style="margin-top: 0.45rem; margin-bottom: 0;">
|
||||
<a class="button is-light" href="{{ compose_page_url }}">
|
||||
<a id="ai-manual-link-{{ person.id }}" class="button is-light" href="{{ compose_page_url }}">
|
||||
<span class="icon is-small"><i class="{{ manual_icon_class }}"></i></span>
|
||||
<span>Manual Text Mode</span>
|
||||
</a>
|
||||
{% if compose_widget_url %}
|
||||
<button
|
||||
id="ai-manual-widget-btn-{{ person.id }}"
|
||||
type="button"
|
||||
class="button is-light is-small js-widget-spawn-trigger is-hidden"
|
||||
data-widget-url="{{ compose_widget_url }}"
|
||||
@@ -111,6 +112,26 @@
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% if send_target_bundle.options %}
|
||||
<div class="field" style="margin-top: 0.55rem; margin-bottom: 0;">
|
||||
<label class="label is-small" style="margin-bottom: 0.25rem;">Target Platform</label>
|
||||
<div class="control">
|
||||
<div class="select is-small is-fullwidth">
|
||||
<select id="ai-target-select-{{ person.id }}" aria-label="Target platform and identifier">
|
||||
{% for option in send_target_bundle.options %}
|
||||
<option
|
||||
value="{{ option.id }}"
|
||||
data-service="{{ option.service }}"
|
||||
data-identifier="{{ option.identifier }}"
|
||||
{% if option.id == send_target_bundle.selected_id %}selected{% endif %}>
|
||||
{{ option.service_label }} · {{ option.identifier }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div id="draft-top-status-{{ person.id }}" style="margin-top: 0.5rem;"></div>
|
||||
</div>
|
||||
|
||||
@@ -719,6 +740,10 @@
|
||||
}
|
||||
const payload = new URLSearchParams();
|
||||
payload.append("draft_text", text);
|
||||
const targetId = getSelectedTargetId();
|
||||
if (targetId) {
|
||||
payload.append("target_identifier_id", targetId);
|
||||
}
|
||||
fetch(queueUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
@@ -740,6 +765,74 @@
|
||||
});
|
||||
};
|
||||
|
||||
function getSelectedTargetId() {
|
||||
const select = document.getElementById("ai-target-select-" + personId);
|
||||
if (!select) {
|
||||
return "";
|
||||
}
|
||||
return String(select.value || "").trim();
|
||||
}
|
||||
|
||||
function syncTargetInputs() {
|
||||
const targetId = getSelectedTargetId();
|
||||
widget.querySelectorAll('input[name="target_identifier_id"]').forEach(function(input) {
|
||||
input.value = targetId;
|
||||
});
|
||||
const engageSelect = document.getElementById("engage-target-select-" + personId);
|
||||
if (engageSelect && targetId) {
|
||||
engageSelect.value = targetId;
|
||||
}
|
||||
syncComposeTargets();
|
||||
}
|
||||
|
||||
function syncComposeTargets() {
|
||||
const targetSelectNode = document.getElementById("ai-target-select-" + personId);
|
||||
if (!targetSelectNode) {
|
||||
return;
|
||||
}
|
||||
const selected = targetSelectNode.options[targetSelectNode.selectedIndex];
|
||||
if (!selected) {
|
||||
return;
|
||||
}
|
||||
const service = String(selected.dataset.service || "").trim();
|
||||
const identifier = String(selected.dataset.identifier || "").trim();
|
||||
if (!service || !identifier) {
|
||||
return;
|
||||
}
|
||||
const params = new URLSearchParams({
|
||||
service: service,
|
||||
identifier: identifier,
|
||||
person: "{{ person.id }}",
|
||||
});
|
||||
const manualLink = document.getElementById("ai-manual-link-" + personId);
|
||||
if (manualLink) {
|
||||
manualLink.href = "{{ compose_page_base_url }}?" + params.toString();
|
||||
}
|
||||
const widgetBtn = document.getElementById("ai-manual-widget-btn-" + personId);
|
||||
if (widgetBtn) {
|
||||
const widgetParams = new URLSearchParams({
|
||||
service: service,
|
||||
identifier: identifier,
|
||||
person: "{{ person.id }}",
|
||||
limit: "{{ limit }}",
|
||||
});
|
||||
const widgetUrl = "{{ compose_widget_base_url }}?" + widgetParams.toString();
|
||||
widgetBtn.dataset.widgetUrl = widgetUrl;
|
||||
widgetBtn.setAttribute("hx-get", widgetUrl);
|
||||
}
|
||||
}
|
||||
|
||||
const targetSelect = document.getElementById("ai-target-select-" + personId);
|
||||
if (targetSelect) {
|
||||
targetSelect.addEventListener("change", function() {
|
||||
syncTargetInputs();
|
||||
});
|
||||
}
|
||||
|
||||
widget.addEventListener("htmx:afterSwap", function() {
|
||||
syncTargetInputs();
|
||||
});
|
||||
|
||||
if (typeof window.giaMitigationShowTab !== "function") {
|
||||
window.giaMitigationShowTab = function(pid, tabName) {
|
||||
const names = ["plan_board", "corrections", "engage", "fundamentals", "ask_ai"];
|
||||
@@ -839,5 +932,6 @@
|
||||
}
|
||||
|
||||
window.giaWorkspaceOpenTab(personId, "plan_board", false);
|
||||
syncTargetInputs();
|
||||
})();
|
||||
</script>
|
||||
|
||||
@@ -2,6 +2,26 @@
|
||||
<div class="is-flex is-justify-content-space-between is-align-items-center" style="gap: 0.5rem; flex-wrap: wrap;">
|
||||
<div>
|
||||
<p class="is-size-7 has-text-weight-semibold" style="margin-bottom: 0.2rem;">Manual Text Mode</p>
|
||||
{% if recent_contacts %}
|
||||
<div class="compose-contact-switch">
|
||||
<div class="select is-small">
|
||||
<select id="{{ panel_id }}-contact-select" class="compose-contact-select">
|
||||
{% for option in recent_contacts %}
|
||||
<option
|
||||
value="{{ option.identifier }}"
|
||||
data-service="{{ option.service }}"
|
||||
data-identifier="{{ option.identifier }}"
|
||||
data-person="{{ option.person_id }}"
|
||||
data-page-url="{{ option.compose_url }}"
|
||||
data-widget-url="{{ option.compose_widget_url }}"
|
||||
{% if option.is_active %}selected{% endif %}>
|
||||
{{ option.person_name }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="is-size-6" style="margin-bottom: 0;">
|
||||
{% if person %}
|
||||
{{ person.name }}
|
||||
@@ -9,11 +29,35 @@
|
||||
{{ identifier }}
|
||||
{% endif %}
|
||||
</p>
|
||||
<p class="is-size-7 compose-meta-line" style="margin-bottom: 0;">
|
||||
{% endif %}
|
||||
<p id="{{ panel_id }}-meta-line" class="is-size-7 compose-meta-line" style="margin-bottom: 0;">
|
||||
{{ service|title }} · {{ identifier }}
|
||||
</p>
|
||||
{% if platform_options %}
|
||||
<div class="compose-platform-switch">
|
||||
<div class="select is-small">
|
||||
<select id="{{ panel_id }}-platform-select" class="compose-platform-select">
|
||||
{% for option in platform_options %}
|
||||
<option
|
||||
value="{{ option.service }}"
|
||||
data-identifier="{{ option.identifier }}"
|
||||
data-person="{{ option.person_id }}"
|
||||
data-page-url="{{ option.page_url }}"
|
||||
data-widget-url="{{ option.widget_url }}"
|
||||
{% if option.is_active %}selected{% endif %}>
|
||||
{{ option.service_label }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="buttons are-small compose-top-actions" style="margin: 0;">
|
||||
<button type="button" class="button is-light is-rounded compose-history-sync-btn">
|
||||
<span class="icon is-small"><i class="fa-solid fa-rotate-right"></i></span>
|
||||
<span>Force Sync</span>
|
||||
</button>
|
||||
<button type="button" class="button is-light is-rounded js-ai-trigger" data-kind="drafts">
|
||||
<span class="icon is-small"><i class="fa-solid fa-pen"></i></span>
|
||||
<span>Drafts</span>
|
||||
@@ -182,6 +226,7 @@
|
||||
data-drafts-url="{{ compose_drafts_url }}"
|
||||
data-summary-url="{{ compose_summary_url }}"
|
||||
data-quick-insights-url="{{ compose_quick_insights_url }}"
|
||||
data-history-sync-url="{{ compose_history_sync_url }}"
|
||||
data-engage-preview-url="{{ compose_engage_preview_url }}"
|
||||
data-engage-send-url="{{ compose_engage_send_url }}">
|
||||
{% for msg in serialized_messages %}
|
||||
@@ -231,7 +276,13 @@
|
||||
</article>
|
||||
</div>
|
||||
{% empty %}
|
||||
<div class="compose-empty-wrap">
|
||||
<p class="compose-empty">No stored messages for this contact yet.</p>
|
||||
<button type="button" class="button is-light is-small compose-history-sync-btn">
|
||||
<span class="icon is-small"><i class="fa-solid fa-rotate-right"></i></span>
|
||||
<span>Force History Sync</span>
|
||||
</button>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<p id="{{ panel_id }}-typing" class="compose-typing is-hidden">
|
||||
@@ -245,16 +296,14 @@
|
||||
hx-post="{% url 'compose_send' %}"
|
||||
hx-target="#{{ panel_id }}-status"
|
||||
hx-swap="innerHTML">
|
||||
<input type="hidden" name="service" value="{{ service }}">
|
||||
<input type="hidden" name="identifier" value="{{ identifier }}">
|
||||
<input id="{{ panel_id }}-input-service" type="hidden" name="service" value="{{ service }}">
|
||||
<input id="{{ panel_id }}-input-identifier" type="hidden" name="identifier" value="{{ identifier }}">
|
||||
<input id="{{ panel_id }}-input-person" type="hidden" name="person" value="{% if person %}{{ person.id }}{% endif %}">
|
||||
<input type="hidden" name="render_mode" value="{{ render_mode }}">
|
||||
<input type="hidden" name="limit" value="{{ limit }}">
|
||||
<input type="hidden" name="panel_id" value="{{ panel_id }}">
|
||||
<input type="hidden" name="failsafe_arm" value="0">
|
||||
<input type="hidden" name="failsafe_confirm" value="0">
|
||||
{% if person %}
|
||||
<input type="hidden" name="person" value="{{ person.id }}">
|
||||
{% endif %}
|
||||
<div class="compose-send-safety">
|
||||
<label class="checkbox is-size-7">
|
||||
<input type="checkbox" class="manual-confirm"> Confirm Send
|
||||
@@ -477,6 +526,19 @@
|
||||
#{{ panel_id }} .compose-msg-meta {
|
||||
margin: 0;
|
||||
}
|
||||
#{{ panel_id }} .compose-platform-switch {
|
||||
margin-top: 0.32rem;
|
||||
}
|
||||
#{{ panel_id }} .compose-contact-switch {
|
||||
margin-bottom: 0.08rem;
|
||||
}
|
||||
#{{ panel_id }} .compose-contact-select {
|
||||
min-width: 15rem;
|
||||
max-width: min(80vw, 30rem);
|
||||
}
|
||||
#{{ panel_id }} .compose-platform-select {
|
||||
min-width: 11rem;
|
||||
}
|
||||
#{{ panel_id }} .compose-gap-artifacts {
|
||||
align-self: center;
|
||||
width: min(92%, 34rem);
|
||||
@@ -554,6 +616,16 @@
|
||||
color: #6f6f6f;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
#{{ panel_id }} .compose-empty-wrap {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
#{{ panel_id }} .compose-history-sync-btn.is-loading {
|
||||
pointer-events: none;
|
||||
opacity: 0.7;
|
||||
}
|
||||
#{{ panel_id }} .compose-typing {
|
||||
margin: 0 0 0.5rem 0.2rem;
|
||||
font-size: 0.78rem;
|
||||
@@ -1062,6 +1134,13 @@
|
||||
const thread = document.getElementById(panelId + "-thread");
|
||||
const form = document.getElementById(panelId + "-form");
|
||||
const textarea = document.getElementById(panelId + "-textarea");
|
||||
const platformSelect = document.getElementById(panelId + "-platform-select");
|
||||
const contactSelect = document.getElementById(panelId + "-contact-select");
|
||||
const metaLine = document.getElementById(panelId + "-meta-line");
|
||||
const hiddenService = document.getElementById(panelId + "-input-service");
|
||||
const hiddenIdentifier = document.getElementById(panelId + "-input-identifier");
|
||||
const hiddenPerson = document.getElementById(panelId + "-input-person");
|
||||
const renderMode = "{{ render_mode }}";
|
||||
if (!thread || !form || !textarea) {
|
||||
return;
|
||||
}
|
||||
@@ -1240,9 +1319,9 @@
|
||||
gap: null,
|
||||
metrics: [],
|
||||
};
|
||||
const personId = String(thread.dataset.person || "").trim();
|
||||
const insightUrlForMetric = function (metricSlug) {
|
||||
const slug = String(metricSlug || "").trim();
|
||||
const personId = String(thread.dataset.person || "").trim();
|
||||
if (!personId || !slug) {
|
||||
return "";
|
||||
}
|
||||
@@ -1273,6 +1352,109 @@
|
||||
}
|
||||
};
|
||||
|
||||
const bindHistorySyncButtons = function (rootNode) {
|
||||
const scope = rootNode || panel;
|
||||
if (!scope) {
|
||||
return;
|
||||
}
|
||||
scope.querySelectorAll(".compose-history-sync-btn").forEach(function (button) {
|
||||
if (button.dataset.bound === "1") {
|
||||
return;
|
||||
}
|
||||
button.dataset.bound = "1";
|
||||
button.addEventListener("click", async function () {
|
||||
const historySyncUrl = String(thread.dataset.historySyncUrl || "").trim();
|
||||
if (!historySyncUrl) {
|
||||
setStatus("History sync endpoint is unavailable.", "warning");
|
||||
return;
|
||||
}
|
||||
button.classList.add("is-loading");
|
||||
setStatus("Requesting history sync…", "info");
|
||||
try {
|
||||
const payload = new URLSearchParams();
|
||||
payload.set("service", thread.dataset.service || "");
|
||||
payload.set("identifier", thread.dataset.identifier || "");
|
||||
if (thread.dataset.person) {
|
||||
payload.set("person", thread.dataset.person);
|
||||
}
|
||||
payload.set("limit", thread.dataset.limit || "60");
|
||||
const response = await fetch(historySyncUrl, {
|
||||
method: "POST",
|
||||
credentials: "same-origin",
|
||||
headers: {
|
||||
"X-CSRFToken": csrfToken,
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
Accept: "application/json"
|
||||
},
|
||||
body: payload.toString(),
|
||||
});
|
||||
const result = await response.json();
|
||||
if (!result.ok) {
|
||||
setStatus(
|
||||
String(result.message || result.error || "History sync failed."),
|
||||
String(result.level || "danger")
|
||||
);
|
||||
} else {
|
||||
setStatus(
|
||||
String(result.message || "History sync requested."),
|
||||
String(result.level || "success")
|
||||
);
|
||||
}
|
||||
await poll(true);
|
||||
if (result.ok) {
|
||||
window.setTimeout(function () {
|
||||
poll(false);
|
||||
}, 1800);
|
||||
window.setTimeout(function () {
|
||||
poll(false);
|
||||
}, 4200);
|
||||
}
|
||||
} catch (err) {
|
||||
setStatus("History sync request failed.", "danger");
|
||||
} finally {
|
||||
button.classList.remove("is-loading");
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const ensureEmptyState = function (messageText) {
|
||||
if (!thread) {
|
||||
return;
|
||||
}
|
||||
if (thread.querySelector(".compose-row")) {
|
||||
return;
|
||||
}
|
||||
thread.querySelectorAll(".compose-empty").forEach(function (node) {
|
||||
if (!node.closest(".compose-empty-wrap")) {
|
||||
node.remove();
|
||||
}
|
||||
});
|
||||
let wrap = thread.querySelector(".compose-empty-wrap");
|
||||
if (!wrap) {
|
||||
wrap = document.createElement("div");
|
||||
wrap.className = "compose-empty-wrap";
|
||||
const empty = document.createElement("p");
|
||||
empty.className = "compose-empty";
|
||||
empty.textContent = String(
|
||||
messageText || "No stored messages for this contact yet."
|
||||
);
|
||||
const button = document.createElement("button");
|
||||
button.type = "button";
|
||||
button.className = "button is-light is-small compose-history-sync-btn";
|
||||
button.innerHTML = '<span class="icon is-small"><i class="fa-solid fa-rotate-right"></i></span><span>Force History Sync</span>';
|
||||
wrap.appendChild(empty);
|
||||
wrap.appendChild(button);
|
||||
thread.appendChild(wrap);
|
||||
} else {
|
||||
const empty = wrap.querySelector(".compose-empty");
|
||||
if (empty && messageText) {
|
||||
empty.textContent = String(messageText);
|
||||
}
|
||||
}
|
||||
bindHistorySyncButtons(wrap);
|
||||
};
|
||||
|
||||
const extractUrlCandidates = function (value) {
|
||||
const raw = String(value || "");
|
||||
const matches = raw.match(/https?:\/\/[^\s<>'"\\]+/g) || [];
|
||||
@@ -1550,6 +1732,10 @@
|
||||
if (empty) {
|
||||
empty.remove();
|
||||
}
|
||||
const emptyWrap = thread.querySelector(".compose-empty-wrap");
|
||||
if (emptyWrap) {
|
||||
emptyWrap.remove();
|
||||
}
|
||||
thread.appendChild(row);
|
||||
wireImageFallbacks(row);
|
||||
updateGlanceFromMessage(msg);
|
||||
@@ -1651,6 +1837,7 @@
|
||||
lastTs = Math.max(lastTs, toInt(payload.last_ts));
|
||||
thread.dataset.lastTs = String(lastTs);
|
||||
}
|
||||
ensureEmptyState();
|
||||
} catch (err) {
|
||||
console.debug("compose poll error", err);
|
||||
} finally {
|
||||
@@ -1729,6 +1916,7 @@
|
||||
// Ignore invalid initial typing state payload.
|
||||
}
|
||||
applyMinuteGrouping();
|
||||
bindHistorySyncButtons(panel);
|
||||
|
||||
const setStatus = function (message, level) {
|
||||
if (!statusBox) {
|
||||
@@ -1815,6 +2003,78 @@
|
||||
return params;
|
||||
};
|
||||
|
||||
const titleCase = function (value) {
|
||||
const raw = String(value || "").trim().toLowerCase();
|
||||
if (!raw) {
|
||||
return "";
|
||||
}
|
||||
if (raw === "whatsapp") {
|
||||
return "WhatsApp";
|
||||
}
|
||||
if (raw === "xmpp") {
|
||||
return "XMPP";
|
||||
}
|
||||
return raw.charAt(0).toUpperCase() + raw.slice(1);
|
||||
};
|
||||
|
||||
const switchThreadContext = function (nextService, nextIdentifier, nextPersonId, pageUrl) {
|
||||
const service = String(nextService || "").trim().toLowerCase();
|
||||
const identifier = String(nextIdentifier || "").trim();
|
||||
const personId = String(nextPersonId || "").trim();
|
||||
if (!service || !identifier) {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
String(thread.dataset.service || "").toLowerCase() === service
|
||||
&& String(thread.dataset.identifier || "") === identifier
|
||||
&& String(thread.dataset.person || "") === personId
|
||||
) {
|
||||
return;
|
||||
}
|
||||
thread.dataset.service = service;
|
||||
thread.dataset.identifier = identifier;
|
||||
if (personId) {
|
||||
thread.dataset.person = personId;
|
||||
} else {
|
||||
delete thread.dataset.person;
|
||||
}
|
||||
if (hiddenService) {
|
||||
hiddenService.value = service;
|
||||
}
|
||||
if (hiddenIdentifier) {
|
||||
hiddenIdentifier.value = identifier;
|
||||
}
|
||||
if (hiddenPerson) {
|
||||
hiddenPerson.value = personId;
|
||||
}
|
||||
if (metaLine) {
|
||||
metaLine.textContent = titleCase(service) + " · " + identifier;
|
||||
}
|
||||
if (panelState.socket) {
|
||||
try {
|
||||
panelState.socket.close();
|
||||
} catch (err) {
|
||||
// Ignore socket close errors.
|
||||
}
|
||||
panelState.socket = null;
|
||||
}
|
||||
panelState.websocketReady = false;
|
||||
hideAllCards();
|
||||
thread.innerHTML = '<p class="compose-empty">Loading messages...</p>';
|
||||
lastTs = 0;
|
||||
thread.dataset.lastTs = "0";
|
||||
glanceState = { gap: null, metrics: [] };
|
||||
renderGlanceItems([]);
|
||||
if (renderMode === "page" && pageUrl) {
|
||||
try {
|
||||
window.history.replaceState({}, "", String(pageUrl));
|
||||
} catch (err) {
|
||||
// Ignore history API failures.
|
||||
}
|
||||
}
|
||||
poll(true);
|
||||
};
|
||||
|
||||
const setCardLoading = function (card, loading) {
|
||||
const loadingNode = card.querySelector(".compose-ai-loading");
|
||||
const contentNode = card.querySelector(".compose-ai-content");
|
||||
@@ -2397,6 +2657,50 @@
|
||||
}
|
||||
});
|
||||
});
|
||||
if (platformSelect) {
|
||||
platformSelect.addEventListener("change", function () {
|
||||
const selected = platformSelect.options[platformSelect.selectedIndex];
|
||||
if (!selected) {
|
||||
return;
|
||||
}
|
||||
const selectedService = selected.value || "";
|
||||
const selectedIdentifier = selected.dataset.identifier || "";
|
||||
const selectedPerson = selected.dataset.person || thread.dataset.person || "";
|
||||
const selectedPageUrl = (
|
||||
renderMode === "page"
|
||||
? selected.dataset.pageUrl
|
||||
: selected.dataset.widgetUrl
|
||||
) || "";
|
||||
switchThreadContext(
|
||||
selectedService,
|
||||
selectedIdentifier,
|
||||
selectedPerson,
|
||||
selectedPageUrl
|
||||
);
|
||||
});
|
||||
}
|
||||
if (contactSelect) {
|
||||
contactSelect.addEventListener("change", function () {
|
||||
const selected = contactSelect.options[contactSelect.selectedIndex];
|
||||
if (!selected) {
|
||||
return;
|
||||
}
|
||||
const selectedService = selected.dataset.service || "";
|
||||
const selectedIdentifier = selected.dataset.identifier || "";
|
||||
const selectedPerson = selected.dataset.person || "";
|
||||
const selectedPageUrl = (
|
||||
renderMode === "page"
|
||||
? selected.dataset.pageUrl
|
||||
: selected.dataset.widgetUrl
|
||||
) || "";
|
||||
switchThreadContext(
|
||||
selectedService,
|
||||
selectedIdentifier,
|
||||
selectedPerson,
|
||||
selectedPageUrl
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
panelState.docClickHandler = function (event) {
|
||||
if (!panel.contains(event.target)) {
|
||||
|
||||
@@ -62,6 +62,7 @@
|
||||
</a>
|
||||
{% else %}
|
||||
<button
|
||||
type="button"
|
||||
hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'
|
||||
hx-get="{% url contacts_url_name type=type pk=item %}"
|
||||
hx-trigger="click"
|
||||
@@ -75,6 +76,7 @@
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'
|
||||
hx-get="{% url chats_url_name type=type pk=item %}"
|
||||
hx-trigger="click"
|
||||
|
||||
@@ -33,6 +33,7 @@
|
||||
</a>
|
||||
{% else %}
|
||||
<button
|
||||
type="button"
|
||||
hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'
|
||||
hx-get="{{ item.compose_widget_url }}"
|
||||
hx-trigger="click"
|
||||
|
||||
@@ -1,26 +1,92 @@
|
||||
{% include 'mixins/partials/notify.html' %}
|
||||
<table
|
||||
class="table is-fullwidth is-hoverable"
|
||||
hx-target="#{{ context_object_name }}-table"
|
||||
id="{{ context_object_name }}-table"
|
||||
<div
|
||||
id="whatsapp-contacts-shell"
|
||||
hx-target="#whatsapp-contacts-shell"
|
||||
hx-swap="outerHTML"
|
||||
hx-trigger="{{ context_object_name_singular }}Event from:body"
|
||||
hx-get="{{ list_url }}">
|
||||
|
||||
<div class="field has-addons is-flex-wrap-wrap" style="margin-bottom: 0.6rem;">
|
||||
<div class="control is-expanded" style="min-width: 14rem;">
|
||||
<input
|
||||
id="whatsapp-contacts-search"
|
||||
class="input"
|
||||
type="text"
|
||||
placeholder="Search WhatsApp contacts...">
|
||||
</div>
|
||||
<div class="control">
|
||||
<button id="whatsapp-contacts-reset" class="button is-light" type="button">
|
||||
Reset
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="osint-results-meta">
|
||||
<div class="osint-results-meta-left">
|
||||
<div class="dropdown is-hoverable" id="whatsapp-contacts-columns-dropdown">
|
||||
<div class="dropdown-trigger">
|
||||
<button class="button is-small is-light" aria-haspopup="true" aria-controls="whatsapp-contacts-columns-menu">
|
||||
<span>Show/Hide Fields</span>
|
||||
<span class="icon is-small"><i class="fa-solid fa-angle-down"></i></span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="dropdown-menu" id="whatsapp-contacts-columns-menu" role="menu">
|
||||
<div class="dropdown-content">
|
||||
<a class="dropdown-item whatsapp-col-toggle" data-col-index="0" href="#" onclick="return false;">
|
||||
<span class="icon is-small"><i class="fa-solid fa-check"></i></span>
|
||||
<span>Name</span>
|
||||
<span class="is-size-7 has-text-grey ml-2">(name)</span>
|
||||
</a>
|
||||
<a class="dropdown-item whatsapp-col-toggle" data-col-index="1" href="#" onclick="return false;">
|
||||
<span class="icon is-small"><i class="fa-solid fa-check"></i></span>
|
||||
<span>Identifier</span>
|
||||
<span class="is-size-7 has-text-grey ml-2">(identifier)</span>
|
||||
</a>
|
||||
<a class="dropdown-item whatsapp-col-toggle" data-col-index="2" href="#" onclick="return false;">
|
||||
<span class="icon is-small"><i class="fa-solid fa-check"></i></span>
|
||||
<span>JID</span>
|
||||
<span class="is-size-7 has-text-grey ml-2">(jid)</span>
|
||||
</a>
|
||||
<a class="dropdown-item whatsapp-col-toggle" data-col-index="3" href="#" onclick="return false;">
|
||||
<span class="icon is-small"><i class="fa-solid fa-check"></i></span>
|
||||
<span>Person</span>
|
||||
<span class="is-size-7 has-text-grey ml-2">(person)</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button class="button is-small is-light" type="button" disabled>
|
||||
<span class="icon is-small"><i class="fa-solid fa-database"></i></span>
|
||||
<span>Static</span>
|
||||
</button>
|
||||
</div>
|
||||
<p class="osint-results-count">
|
||||
fetched {{ object_list|length }} result{% if object_list|length != 1 %}s{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="table-container">
|
||||
<table
|
||||
class="table is-fullwidth is-hoverable"
|
||||
id="whatsapp-contacts-table">
|
||||
<thead>
|
||||
<th>name</th>
|
||||
<th>identifier</th>
|
||||
<th>jid</th>
|
||||
<th>person</th>
|
||||
<th>actions</th>
|
||||
</thead>
|
||||
{% for item in object_list %}
|
||||
<tr>
|
||||
<td>{{ item.name|default:"-" }}</td>
|
||||
<td>
|
||||
<th data-whatsapp-col="0" class="whatsapp-col-0">name</th>
|
||||
<th data-whatsapp-col="1" class="whatsapp-col-1">identifier</th>
|
||||
<th data-whatsapp-col="2" class="whatsapp-col-2">jid</th>
|
||||
<th data-whatsapp-col="3" class="whatsapp-col-3">person</th>
|
||||
<th>actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for item in object_list %}
|
||||
<tr data-search="{{ item.name|default:'-'|lower }} {{ item.identifier|lower }} {{ item.jid|default:'-'|lower }} {{ item.person_name|default:'-'|lower }}">
|
||||
<td data-whatsapp-col="0" class="whatsapp-col-0">{{ item.name|default:"-" }}</td>
|
||||
<td data-whatsapp-col="1" class="whatsapp-col-1">
|
||||
<code>{{ item.identifier }}</code>
|
||||
</td>
|
||||
<td>{{ item.jid|default:"-" }}</td>
|
||||
<td>{{ item.person_name|default:"-" }}</td>
|
||||
<td data-whatsapp-col="2" class="whatsapp-col-2">{{ item.jid|default:"-" }}</td>
|
||||
<td data-whatsapp-col="3" class="whatsapp-col-3">{{ item.person_name|default:"-" }}</td>
|
||||
<td>
|
||||
<div class="buttons">
|
||||
{% if type == 'page' %}
|
||||
@@ -29,6 +95,7 @@
|
||||
</a>
|
||||
{% else %}
|
||||
<button
|
||||
type="button"
|
||||
hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'
|
||||
hx-get="{{ item.compose_widget_url }}"
|
||||
hx-trigger="click"
|
||||
@@ -50,4 +117,115 @@
|
||||
<td colspan="5" class="has-text-grey">No WhatsApp contacts discovered yet.</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
#whatsapp-contacts-shell .osint-results-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.6rem;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 0.6rem;
|
||||
}
|
||||
#whatsapp-contacts-shell .osint-results-meta-left {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.42rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
#whatsapp-contacts-shell .osint-results-count {
|
||||
margin: 0;
|
||||
color: #6c757d;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
#whatsapp-contacts-shell .whatsapp-col-toggle .icon {
|
||||
color: #3273dc;
|
||||
}
|
||||
#whatsapp-contacts-shell .whatsapp-col-toggle.is-hidden-col .icon {
|
||||
color: #b5b5b5;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
const shell = document.getElementById("whatsapp-contacts-shell");
|
||||
if (!shell) return;
|
||||
const table = document.getElementById("whatsapp-contacts-table");
|
||||
if (!table) return;
|
||||
const rows = Array.from(table.querySelectorAll("tbody tr[data-search]"));
|
||||
const searchInput = document.getElementById("whatsapp-contacts-search");
|
||||
const resetBtn = document.getElementById("whatsapp-contacts-reset");
|
||||
const toggles = Array.from(shell.querySelectorAll(".whatsapp-col-toggle"));
|
||||
const storageKey = "gia_whatsapp_contacts_hidden_cols_v1";
|
||||
let hidden = [];
|
||||
try {
|
||||
hidden = JSON.parse(localStorage.getItem(storageKey) || "[]");
|
||||
} catch (e) {
|
||||
hidden = [];
|
||||
}
|
||||
if (!Array.isArray(hidden)) hidden = [];
|
||||
hidden = hidden.map(String);
|
||||
|
||||
function applyFilters() {
|
||||
const query = String(searchInput?.value || "").trim().toLowerCase();
|
||||
rows.forEach((row) => {
|
||||
const hay = String(row.dataset.search || "").toLowerCase();
|
||||
row.style.display = !query || hay.includes(query) ? "" : "none";
|
||||
});
|
||||
}
|
||||
|
||||
function applyColumns() {
|
||||
const hiddenSet = new Set(hidden);
|
||||
shell.querySelectorAll("[data-whatsapp-col]").forEach((node) => {
|
||||
const idx = String(node.getAttribute("data-whatsapp-col") || "");
|
||||
node.style.display = hiddenSet.has(idx) ? "none" : "";
|
||||
});
|
||||
toggles.forEach((toggle) => {
|
||||
const idx = String(toggle.getAttribute("data-col-index") || "");
|
||||
const isHidden = hiddenSet.has(idx);
|
||||
toggle.classList.toggle("is-hidden-col", isHidden);
|
||||
const icon = toggle.querySelector("i");
|
||||
if (icon) {
|
||||
icon.className = isHidden ? "fa-solid fa-xmark" : "fa-solid fa-check";
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function persistColumns() {
|
||||
try {
|
||||
localStorage.setItem(storageKey, JSON.stringify(hidden));
|
||||
} catch (e) {
|
||||
// Ignore storage failures.
|
||||
}
|
||||
}
|
||||
|
||||
toggles.forEach((toggle) => {
|
||||
toggle.addEventListener("click", function () {
|
||||
const idx = String(toggle.getAttribute("data-col-index") || "");
|
||||
if (!idx) return;
|
||||
if (hidden.includes(idx)) {
|
||||
hidden = hidden.filter((item) => item !== idx);
|
||||
} else {
|
||||
hidden.push(idx);
|
||||
}
|
||||
persistColumns();
|
||||
applyColumns();
|
||||
});
|
||||
});
|
||||
|
||||
if (searchInput) searchInput.addEventListener("input", applyFilters);
|
||||
if (resetBtn) {
|
||||
resetBtn.addEventListener("click", function () {
|
||||
if (searchInput) searchInput.value = "";
|
||||
applyFilters();
|
||||
});
|
||||
}
|
||||
|
||||
applyFilters();
|
||||
applyColumns();
|
||||
})();
|
||||
</script>
|
||||
</div>
|
||||
|
||||
@@ -46,6 +46,10 @@ COMPOSE_WS_TOKEN_SALT = "compose-ws"
|
||||
COMPOSE_ENGAGE_TOKEN_SALT = "compose-engage"
|
||||
COMPOSE_AI_CACHE_TTL = 60 * 30
|
||||
URL_PATTERN = re.compile(r"https?://[^\s<>'\"\\]+")
|
||||
SIGNAL_UUID_PATTERN = re.compile(
|
||||
r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
IMAGE_EXTENSIONS = (
|
||||
".png",
|
||||
".jpg",
|
||||
@@ -1349,6 +1353,42 @@ def _service_icon_class(service: str) -> str:
|
||||
return "fa-solid fa-address-card"
|
||||
|
||||
|
||||
def _service_label(service: str) -> str:
|
||||
key = str(service or "").strip().lower()
|
||||
labels = {
|
||||
"signal": "Signal",
|
||||
"whatsapp": "WhatsApp",
|
||||
"instagram": "Instagram",
|
||||
"xmpp": "XMPP",
|
||||
}
|
||||
return labels.get(key, key.title() if key else "Unknown")
|
||||
|
||||
|
||||
def _service_order(service: str) -> int:
|
||||
key = str(service or "").strip().lower()
|
||||
order = {
|
||||
"signal": 0,
|
||||
"whatsapp": 1,
|
||||
"instagram": 2,
|
||||
"xmpp": 3,
|
||||
}
|
||||
return order.get(key, 99)
|
||||
|
||||
|
||||
def _signal_identifier_shape(value: str) -> str:
|
||||
raw = str(value or "").strip()
|
||||
if not raw:
|
||||
return "unknown"
|
||||
if SIGNAL_UUID_PATTERN.fullmatch(raw):
|
||||
return "uuid"
|
||||
digits = re.sub(r"[^0-9]", "", raw)
|
||||
if digits and raw.replace("+", "").replace(" ", "").replace("-", "").isdigit():
|
||||
return "phone"
|
||||
if digits and raw.isdigit():
|
||||
return "phone"
|
||||
return "other"
|
||||
|
||||
|
||||
def _manual_contact_rows(user):
|
||||
rows = []
|
||||
seen = set()
|
||||
@@ -1397,6 +1437,7 @@ def _manual_contact_rows(user):
|
||||
{
|
||||
"person_name": person_name,
|
||||
"linked_person_name": linked_person_name,
|
||||
"person_id": str(person.id) if person else "",
|
||||
"detected_name": detected,
|
||||
"service": service_key,
|
||||
"service_icon_class": _service_icon_class(service_key),
|
||||
@@ -1489,7 +1530,94 @@ def _manual_contact_rows(user):
|
||||
detected_name=detected_name,
|
||||
)
|
||||
|
||||
rows.sort(key=lambda row: (row["person_name"].lower(), row["service"], row["identifier"]))
|
||||
rows.sort(
|
||||
key=lambda row: (
|
||||
0 if row.get("linked_person") else 1,
|
||||
row["person_name"].lower(),
|
||||
_service_order(row.get("service")),
|
||||
row["identifier"],
|
||||
)
|
||||
)
|
||||
return rows
|
||||
|
||||
|
||||
def _recent_manual_contacts(
|
||||
user,
|
||||
*,
|
||||
current_service: str,
|
||||
current_identifier: str,
|
||||
current_person: Person | None,
|
||||
limit: int = 12,
|
||||
):
|
||||
all_rows = _manual_contact_rows(user)
|
||||
if not all_rows:
|
||||
return []
|
||||
|
||||
row_by_key = {
|
||||
(str(row.get("service") or "").strip().lower(), str(row.get("identifier") or "").strip()): row
|
||||
for row in all_rows
|
||||
}
|
||||
ordered_keys = []
|
||||
seen_keys = set()
|
||||
recent_values = (
|
||||
Message.objects.filter(
|
||||
user=user,
|
||||
session__identifier__isnull=False,
|
||||
)
|
||||
.values_list(
|
||||
"session__identifier__service",
|
||||
"session__identifier__identifier",
|
||||
)
|
||||
.order_by("-ts", "-id")[:1000]
|
||||
)
|
||||
for service_value, identifier_value in recent_values:
|
||||
key = (
|
||||
_default_service(service_value),
|
||||
str(identifier_value or "").strip(),
|
||||
)
|
||||
if not key[1] or key in seen_keys:
|
||||
continue
|
||||
seen_keys.add(key)
|
||||
ordered_keys.append(key)
|
||||
if len(ordered_keys) >= limit:
|
||||
break
|
||||
|
||||
current_key = (_default_service(current_service), str(current_identifier or "").strip())
|
||||
if current_key[1]:
|
||||
if current_key in ordered_keys:
|
||||
ordered_keys.remove(current_key)
|
||||
ordered_keys.insert(0, current_key)
|
||||
if len(ordered_keys) > limit:
|
||||
ordered_keys = ordered_keys[:limit]
|
||||
|
||||
rows = []
|
||||
for service_key, identifier_value in ordered_keys:
|
||||
row = dict(row_by_key.get((service_key, identifier_value)) or {})
|
||||
if not row:
|
||||
urls = _compose_urls(
|
||||
service_key,
|
||||
identifier_value,
|
||||
current_person.id if current_person else None,
|
||||
)
|
||||
row = {
|
||||
"person_name": identifier_value,
|
||||
"linked_person_name": "",
|
||||
"detected_name": "",
|
||||
"service": service_key,
|
||||
"service_icon_class": _service_icon_class(service_key),
|
||||
"identifier": identifier_value,
|
||||
"compose_url": urls["page_url"],
|
||||
"compose_widget_url": urls["widget_url"],
|
||||
"linked_person": False,
|
||||
"source": "recent",
|
||||
}
|
||||
row["service_label"] = _service_label(service_key)
|
||||
row["person_id"] = str(row.get("person_id") or "")
|
||||
row["is_active"] = (
|
||||
service_key == _default_service(current_service)
|
||||
and identifier_value == str(current_identifier or "").strip()
|
||||
)
|
||||
rows.append(row)
|
||||
return rows
|
||||
|
||||
|
||||
@@ -1591,8 +1719,60 @@ def _panel_context(
|
||||
identifier=base["identifier"],
|
||||
person_id=base["person"].id if base["person"] else None,
|
||||
)
|
||||
ws_url = ""
|
||||
if bool(getattr(settings, "COMPOSE_WS_ENABLED", False)):
|
||||
ws_url = f"/ws/compose/thread/?{urlencode({'token': ws_token})}"
|
||||
|
||||
platform_options = []
|
||||
if base["person"] is not None:
|
||||
linked_identifiers = list(
|
||||
PersonIdentifier.objects.filter(
|
||||
user=request.user,
|
||||
person=base["person"],
|
||||
).order_by("service", "id")
|
||||
)
|
||||
by_service = {}
|
||||
for row in linked_identifiers:
|
||||
service_key = _default_service(row.service)
|
||||
identifier_value = str(row.identifier or "").strip()
|
||||
if not identifier_value:
|
||||
continue
|
||||
if service_key not in by_service:
|
||||
by_service[service_key] = identifier_value
|
||||
if base["service"] and base["identifier"]:
|
||||
by_service[base["service"]] = base["identifier"]
|
||||
|
||||
for service_key in sorted(by_service.keys(), key=_service_order):
|
||||
identifier_value = by_service[service_key]
|
||||
option_urls = _compose_urls(service_key, identifier_value, base["person"].id)
|
||||
platform_options.append(
|
||||
{
|
||||
"service": service_key,
|
||||
"service_label": _service_label(service_key),
|
||||
"identifier": identifier_value,
|
||||
"person_id": str(base["person"].id),
|
||||
"page_url": option_urls["page_url"],
|
||||
"widget_url": option_urls["widget_url"],
|
||||
"is_active": (
|
||||
service_key == base["service"]
|
||||
and identifier_value == base["identifier"]
|
||||
),
|
||||
}
|
||||
)
|
||||
elif base["identifier"]:
|
||||
option_urls = _compose_urls(base["service"], base["identifier"], None)
|
||||
platform_options.append(
|
||||
{
|
||||
"service": base["service"],
|
||||
"service_label": _service_label(base["service"]),
|
||||
"identifier": base["identifier"],
|
||||
"person_id": "",
|
||||
"page_url": option_urls["page_url"],
|
||||
"widget_url": option_urls["widget_url"],
|
||||
"is_active": True,
|
||||
}
|
||||
)
|
||||
|
||||
unique_raw = (
|
||||
f"{base['service']}|{base['identifier']}|{request.user.id}|{time.time_ns()}"
|
||||
)
|
||||
@@ -1601,6 +1781,13 @@ def _panel_context(
|
||||
user_id=request.user.id,
|
||||
person_id=base["person"].id if base["person"] else None,
|
||||
)
|
||||
recent_contacts = _recent_manual_contacts(
|
||||
request.user,
|
||||
current_service=base["service"],
|
||||
current_identifier=base["identifier"],
|
||||
current_person=base["person"],
|
||||
limit=12,
|
||||
)
|
||||
|
||||
return {
|
||||
"service": base["service"],
|
||||
@@ -1627,6 +1814,7 @@ def _panel_context(
|
||||
"compose_engage_preview_url": reverse("compose_engage_preview"),
|
||||
"compose_engage_send_url": reverse("compose_engage_send"),
|
||||
"compose_quick_insights_url": reverse("compose_quick_insights"),
|
||||
"compose_history_sync_url": reverse("compose_history_sync"),
|
||||
"compose_ws_url": ws_url,
|
||||
"ai_workspace_url": (
|
||||
f"{reverse('ai_workspace')}?person={base['person'].id}"
|
||||
@@ -1644,6 +1832,8 @@ def _panel_context(
|
||||
"manual_icon_class": "fa-solid fa-paper-plane",
|
||||
"panel_id": f"compose-panel-{unique}",
|
||||
"typing_state_json": json.dumps(typing_state),
|
||||
"platform_options": platform_options,
|
||||
"recent_contacts": recent_contacts,
|
||||
}
|
||||
|
||||
|
||||
@@ -1757,6 +1947,28 @@ class ComposeContactMatch(LoginRequiredMixin, View):
|
||||
def get(self, request):
|
||||
return render(request, self.template_name, self._context(request))
|
||||
|
||||
def _signal_companion_identifiers(self, identifier: str) -> set[str]:
|
||||
value = str(identifier or "").strip()
|
||||
if not value:
|
||||
return set()
|
||||
source_shape = _signal_identifier_shape(value)
|
||||
companions = set()
|
||||
signal_rows = Chat.objects.filter(source_uuid=value) | Chat.objects.filter(
|
||||
source_number=value
|
||||
)
|
||||
for chat in signal_rows.order_by("-id")[:1000]:
|
||||
for candidate in (chat.source_uuid, chat.source_number):
|
||||
cleaned = str(candidate or "").strip()
|
||||
if not cleaned or cleaned == value:
|
||||
continue
|
||||
# Keep auto-linking conservative: only same-shape companions.
|
||||
if source_shape != "other":
|
||||
candidate_shape = _signal_identifier_shape(cleaned)
|
||||
if candidate_shape != source_shape:
|
||||
continue
|
||||
companions.add(cleaned)
|
||||
return companions
|
||||
|
||||
def post(self, request):
|
||||
person_id = str(request.POST.get("person_id") or "").strip()
|
||||
person_name = str(request.POST.get("person_name") or "").strip()
|
||||
@@ -1800,6 +2012,38 @@ class ComposeContactMatch(LoginRequiredMixin, View):
|
||||
message = f"Re-linked {identifier} ({service}) to {person.name}."
|
||||
else:
|
||||
message = f"{identifier} ({service}) is already linked to {person.name}."
|
||||
|
||||
linked_companions = 0
|
||||
skipped_companions = 0
|
||||
if service == "signal":
|
||||
companions = self._signal_companion_identifiers(identifier)
|
||||
for candidate in companions:
|
||||
existing = PersonIdentifier.objects.filter(
|
||||
user=request.user,
|
||||
service="signal",
|
||||
identifier=candidate,
|
||||
).first()
|
||||
if existing is None:
|
||||
PersonIdentifier.objects.create(
|
||||
user=request.user,
|
||||
person=person,
|
||||
service="signal",
|
||||
identifier=candidate,
|
||||
)
|
||||
linked_companions += 1
|
||||
continue
|
||||
if existing.person_id != person.id:
|
||||
skipped_companions += 1
|
||||
if linked_companions:
|
||||
message = (
|
||||
f"{message} Added {linked_companions} companion Signal identifier"
|
||||
f"{'' if linked_companions == 1 else 's'}."
|
||||
)
|
||||
if skipped_companions:
|
||||
message = (
|
||||
f"{message} Skipped {skipped_companions} companion identifier"
|
||||
f"{'' if skipped_companions == 1 else 's'} already linked to another person."
|
||||
)
|
||||
return render(
|
||||
request,
|
||||
self.template_name,
|
||||
@@ -1880,12 +2124,24 @@ class ComposeThread(LoginRequiredMixin, View):
|
||||
latest_ts = after_ts
|
||||
messages = []
|
||||
seed_previous = None
|
||||
session_ids = ComposeHistorySync._session_ids_for_scope(
|
||||
user=request.user,
|
||||
person=base["person"],
|
||||
service=service,
|
||||
person_identifier=base["person_identifier"],
|
||||
explicit_identifier=base["identifier"],
|
||||
)
|
||||
if base["person_identifier"] is not None:
|
||||
session, _ = ChatSession.objects.get_or_create(
|
||||
user=request.user,
|
||||
identifier=base["person_identifier"],
|
||||
)
|
||||
base_queryset = Message.objects.filter(user=request.user, session=session)
|
||||
session_ids = list({*session_ids, int(session.id)})
|
||||
if session_ids:
|
||||
base_queryset = Message.objects.filter(
|
||||
user=request.user,
|
||||
session_id__in=session_ids,
|
||||
)
|
||||
queryset = base_queryset
|
||||
if after_ts > 0:
|
||||
seed_previous = (
|
||||
@@ -1901,7 +2157,10 @@ class ComposeThread(LoginRequiredMixin, View):
|
||||
.order_by("ts")[:limit]
|
||||
)
|
||||
newest = (
|
||||
Message.objects.filter(user=request.user, session=session)
|
||||
Message.objects.filter(
|
||||
user=request.user,
|
||||
session_id__in=session_ids,
|
||||
)
|
||||
.order_by("-ts")
|
||||
.values_list("ts", flat=True)
|
||||
.first()
|
||||
@@ -1928,6 +2187,284 @@ class ComposeThread(LoginRequiredMixin, View):
|
||||
return JsonResponse(payload)
|
||||
|
||||
|
||||
class ComposeHistorySync(LoginRequiredMixin, View):
|
||||
@staticmethod
|
||||
def _session_ids_for_identifier(user, person_identifier):
|
||||
if person_identifier is None:
|
||||
return []
|
||||
return list(
|
||||
ChatSession.objects.filter(
|
||||
user=user,
|
||||
identifier=person_identifier,
|
||||
).values_list("id", flat=True)
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _identifier_variants(service: str, identifier: str):
|
||||
raw = str(identifier or "").strip()
|
||||
if not raw:
|
||||
return []
|
||||
values = {raw}
|
||||
if service == "whatsapp":
|
||||
digits = re.sub(r"[^0-9]", "", raw)
|
||||
if digits:
|
||||
values.add(digits)
|
||||
values.add(f"+{digits}")
|
||||
values.add(f"{digits}@s.whatsapp.net")
|
||||
if "@" in raw:
|
||||
local = raw.split("@", 1)[0].strip()
|
||||
if local:
|
||||
values.add(local)
|
||||
return [value for value in values if value]
|
||||
|
||||
@classmethod
|
||||
def _session_ids_for_scope(
|
||||
cls,
|
||||
user,
|
||||
person,
|
||||
service: str,
|
||||
person_identifier,
|
||||
explicit_identifier: str,
|
||||
):
|
||||
identifiers = []
|
||||
if person_identifier is not None:
|
||||
identifiers.append(person_identifier)
|
||||
if person is not None:
|
||||
identifiers.extend(
|
||||
list(
|
||||
PersonIdentifier.objects.filter(
|
||||
user=user,
|
||||
person=person,
|
||||
service=service,
|
||||
)
|
||||
)
|
||||
)
|
||||
variants = cls._identifier_variants(service, explicit_identifier)
|
||||
if variants:
|
||||
identifiers.extend(
|
||||
list(
|
||||
PersonIdentifier.objects.filter(
|
||||
user=user,
|
||||
service=service,
|
||||
identifier__in=variants,
|
||||
)
|
||||
)
|
||||
)
|
||||
unique_ids = []
|
||||
seen = set()
|
||||
for row in identifiers:
|
||||
row_id = int(row.id)
|
||||
if row_id in seen:
|
||||
continue
|
||||
seen.add(row_id)
|
||||
unique_ids.append(row_id)
|
||||
if not unique_ids:
|
||||
return []
|
||||
return list(
|
||||
ChatSession.objects.filter(
|
||||
user=user,
|
||||
identifier_id__in=unique_ids,
|
||||
).values_list("id", flat=True)
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _reconcile_duplicate_messages(user, session_ids):
|
||||
if not session_ids:
|
||||
return 0
|
||||
rows = list(
|
||||
Message.objects.filter(
|
||||
user=user,
|
||||
session_id__in=session_ids,
|
||||
)
|
||||
.order_by("id")
|
||||
.values("id", "session_id", "ts", "sender_uuid", "text", "custom_author")
|
||||
)
|
||||
seen = {}
|
||||
duplicate_ids = []
|
||||
for row in rows:
|
||||
dedupe_key = (
|
||||
int(row.get("session_id") or 0),
|
||||
int(row.get("ts") or 0),
|
||||
str(row.get("sender_uuid") or ""),
|
||||
str(row.get("text") or ""),
|
||||
str(row.get("custom_author") or ""),
|
||||
)
|
||||
if dedupe_key in seen:
|
||||
duplicate_ids.append(row["id"])
|
||||
continue
|
||||
seen[dedupe_key] = row["id"]
|
||||
if not duplicate_ids:
|
||||
return 0
|
||||
Message.objects.filter(user=user, id__in=duplicate_ids).delete()
|
||||
return len(duplicate_ids)
|
||||
|
||||
def post(self, request):
|
||||
service = _default_service(request.POST.get("service"))
|
||||
identifier = str(request.POST.get("identifier") or "").strip()
|
||||
person = None
|
||||
person_id = request.POST.get("person")
|
||||
if person_id:
|
||||
person = get_object_or_404(Person, id=person_id, user=request.user)
|
||||
if not identifier and person is None:
|
||||
return JsonResponse(
|
||||
{"ok": False, "message": "Missing contact identifier.", "level": "danger"}
|
||||
)
|
||||
|
||||
base = _context_base(request.user, service, identifier, person)
|
||||
if base["person_identifier"] is None:
|
||||
return JsonResponse(
|
||||
{
|
||||
"ok": False,
|
||||
"message": "No linked identifier for this contact yet.",
|
||||
"level": "warning",
|
||||
}
|
||||
)
|
||||
|
||||
session_ids = self._session_ids_for_scope(
|
||||
user=request.user,
|
||||
person=base["person"],
|
||||
service=base["service"],
|
||||
person_identifier=base["person_identifier"],
|
||||
explicit_identifier=base["identifier"],
|
||||
)
|
||||
before_count = 0
|
||||
if session_ids:
|
||||
before_count = Message.objects.filter(
|
||||
user=request.user,
|
||||
session_id__in=session_ids,
|
||||
).count()
|
||||
|
||||
runtime_result = {}
|
||||
if base["service"] == "whatsapp":
|
||||
command_id = transport.enqueue_runtime_command(
|
||||
"whatsapp",
|
||||
"force_history_sync",
|
||||
{
|
||||
"identifier": base["identifier"],
|
||||
"person_id": str(base["person"].id) if base["person"] else "",
|
||||
},
|
||||
)
|
||||
runtime_result = async_to_sync(transport.wait_runtime_command_result)(
|
||||
"whatsapp",
|
||||
command_id,
|
||||
timeout=25,
|
||||
)
|
||||
if runtime_result is None:
|
||||
return JsonResponse(
|
||||
{
|
||||
"ok": False,
|
||||
"message": (
|
||||
"History sync timed out. Runtime may still be processing; "
|
||||
"watch Runtime Debug and retry."
|
||||
),
|
||||
"level": "warning",
|
||||
}
|
||||
)
|
||||
if not runtime_result.get("ok"):
|
||||
error_text = str(runtime_result.get("error") or "history_sync_failed")
|
||||
return JsonResponse(
|
||||
{
|
||||
"ok": False,
|
||||
"message": f"History sync failed: {error_text}",
|
||||
"level": "danger",
|
||||
}
|
||||
)
|
||||
else:
|
||||
return JsonResponse(
|
||||
{
|
||||
"ok": False,
|
||||
"message": (
|
||||
f"Force history sync is only available for WhatsApp right now "
|
||||
f"(current: {base['service']})."
|
||||
),
|
||||
"level": "warning",
|
||||
}
|
||||
)
|
||||
|
||||
session_ids = self._session_ids_for_scope(
|
||||
user=request.user,
|
||||
person=base["person"],
|
||||
service=base["service"],
|
||||
person_identifier=base["person_identifier"],
|
||||
explicit_identifier=base["identifier"],
|
||||
)
|
||||
raw_after_count = 0
|
||||
if session_ids:
|
||||
raw_after_count = Message.objects.filter(
|
||||
user=request.user,
|
||||
session_id__in=session_ids,
|
||||
).count()
|
||||
dedup_removed = self._reconcile_duplicate_messages(request.user, session_ids)
|
||||
after_count = raw_after_count
|
||||
if dedup_removed > 0:
|
||||
after_count = Message.objects.filter(
|
||||
user=request.user,
|
||||
session_id__in=session_ids,
|
||||
).count()
|
||||
|
||||
imported_count = max(0, int(raw_after_count) - int(before_count))
|
||||
net_new_count = max(0, int(after_count) - int(before_count))
|
||||
delta = max(0, int(after_count) - int(before_count))
|
||||
if delta > 0:
|
||||
detail = []
|
||||
if imported_count:
|
||||
detail.append(f"imported {imported_count}")
|
||||
if dedup_removed:
|
||||
detail.append(f"reconciled {dedup_removed} duplicate(s)")
|
||||
suffix = f" ({', '.join(detail)})" if detail else ""
|
||||
return JsonResponse(
|
||||
{
|
||||
"ok": True,
|
||||
"message": f"History sync complete. Net +{net_new_count} message(s){suffix}.",
|
||||
"level": "success",
|
||||
"new_messages": net_new_count,
|
||||
"imported_messages": imported_count,
|
||||
"reconciled_duplicates": dedup_removed,
|
||||
"before": before_count,
|
||||
"after": after_count,
|
||||
"runtime_result": runtime_result,
|
||||
}
|
||||
)
|
||||
if dedup_removed > 0:
|
||||
return JsonResponse(
|
||||
{
|
||||
"ok": True,
|
||||
"message": (
|
||||
f"History sync complete. Reconciled {dedup_removed} duplicate message(s)."
|
||||
),
|
||||
"level": "success",
|
||||
"new_messages": 0,
|
||||
"imported_messages": imported_count,
|
||||
"reconciled_duplicates": dedup_removed,
|
||||
"before": before_count,
|
||||
"after": after_count,
|
||||
"runtime_result": runtime_result,
|
||||
}
|
||||
)
|
||||
return JsonResponse(
|
||||
{
|
||||
"ok": True,
|
||||
"message": (
|
||||
(
|
||||
"History sync completed, but this WhatsApp runtime session does not expose "
|
||||
"message text history yet "
|
||||
f"({str(runtime_result.get('sqlite_error') or 'no_message_history_source')}). "
|
||||
"Live incoming/outgoing messages will continue to sync."
|
||||
)
|
||||
if str(runtime_result.get("sqlite_error") or "").strip()
|
||||
else "History sync completed. No new messages were found yet; retry in a few seconds."
|
||||
),
|
||||
"level": "info",
|
||||
"new_messages": 0,
|
||||
"imported_messages": imported_count,
|
||||
"reconciled_duplicates": dedup_removed,
|
||||
"before": before_count,
|
||||
"after": after_count,
|
||||
"runtime_result": runtime_result,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class ComposeMediaBlob(LoginRequiredMixin, View):
|
||||
"""
|
||||
Serve cached media blobs for authenticated compose image previews.
|
||||
@@ -2151,13 +2688,15 @@ class ComposeQuickInsights(LoginRequiredMixin, View):
|
||||
|
||||
payload = _quick_insights_rows(conversation)
|
||||
participant_state = _participant_feedback_state_label(conversation, person)
|
||||
selected_platform_label = _service_label(base["service"])
|
||||
return JsonResponse(
|
||||
{
|
||||
"ok": True,
|
||||
"empty": False,
|
||||
"summary": {
|
||||
"person_name": person.name,
|
||||
"platform": conversation.get_platform_type_display(),
|
||||
"platform": selected_platform_label,
|
||||
"platform_scope": "All linked platforms",
|
||||
"state": participant_state
|
||||
or conversation.get_stability_state_display(),
|
||||
"stability_state": conversation.get_stability_state_display(),
|
||||
@@ -2194,6 +2733,7 @@ class ComposeQuickInsights(LoginRequiredMixin, View):
|
||||
"Each row shows current value, percent change vs previous point, and data-point count.",
|
||||
"Arrow color indicates improving or risk direction for that metric.",
|
||||
"State uses participant feedback (Withdrawing/Overextending/Balanced) when available.",
|
||||
"Values are computed from all linked platform messages for this person.",
|
||||
"Face indicator maps value range to positive, mixed, or strained climate.",
|
||||
"Use this card for fast triage; open AI Workspace for full graphs and details.",
|
||||
],
|
||||
|
||||
@@ -263,6 +263,18 @@ class WhatsAppChatsList(WhatsAppContactsList):
|
||||
|
||||
def get_queryset(self, *args, **kwargs):
|
||||
rows = []
|
||||
seen = set()
|
||||
state = transport.get_runtime_state("whatsapp")
|
||||
runtime_contacts = state.get("contacts") or []
|
||||
runtime_name_map = {}
|
||||
for item in runtime_contacts:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
identifier = str(item.get("identifier") or "").strip()
|
||||
if not identifier:
|
||||
continue
|
||||
runtime_name_map[identifier] = str(item.get("name") or "").strip()
|
||||
|
||||
sessions = (
|
||||
ChatSession.objects.filter(
|
||||
user=self.request.user,
|
||||
@@ -273,8 +285,9 @@ class WhatsAppChatsList(WhatsAppContactsList):
|
||||
)
|
||||
for session in sessions:
|
||||
identifier = str(session.identifier.identifier or "").strip()
|
||||
if not identifier:
|
||||
if not identifier or identifier in seen:
|
||||
continue
|
||||
seen.add(identifier)
|
||||
latest = (
|
||||
Message.objects.filter(user=self.request.user, session=session)
|
||||
.order_by("-ts")
|
||||
@@ -284,15 +297,17 @@ class WhatsAppChatsList(WhatsAppContactsList):
|
||||
preview = str((latest.text if latest else "") or "").strip()
|
||||
if len(preview) > 80:
|
||||
preview = f"{preview[:77]}..."
|
||||
display_name = (
|
||||
preview
|
||||
or runtime_name_map.get(identifier)
|
||||
or session.identifier.person.name
|
||||
or "WhatsApp Chat"
|
||||
)
|
||||
rows.append(
|
||||
{
|
||||
"identifier": identifier,
|
||||
"jid": identifier,
|
||||
"name": (
|
||||
preview
|
||||
or session.identifier.person.name
|
||||
or "WhatsApp Chat"
|
||||
),
|
||||
"name": display_name,
|
||||
"service_icon_class": _service_icon_class("whatsapp"),
|
||||
"person_name": session.identifier.person.name,
|
||||
"compose_page_url": urls["page_url"],
|
||||
@@ -304,6 +319,41 @@ class WhatsAppChatsList(WhatsAppContactsList):
|
||||
"last_ts": int(latest.ts or 0) if latest else 0,
|
||||
}
|
||||
)
|
||||
# Fallback: show synced WhatsApp contacts as chat entries even when no
|
||||
# local message history exists yet.
|
||||
for item in runtime_contacts:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
identifier = str(item.get("identifier") or item.get("jid") or "").strip()
|
||||
if not identifier:
|
||||
continue
|
||||
identifier = identifier.split("@", 1)[0].strip()
|
||||
if not identifier or identifier in seen:
|
||||
continue
|
||||
seen.add(identifier)
|
||||
linked = self._linked_identifier(identifier, str(item.get("jid") or ""))
|
||||
urls = _compose_urls(
|
||||
"whatsapp",
|
||||
identifier,
|
||||
linked.person_id if linked else None,
|
||||
)
|
||||
rows.append(
|
||||
{
|
||||
"identifier": identifier,
|
||||
"jid": str(item.get("jid") or identifier).strip(),
|
||||
"name": str(item.get("name") or "WhatsApp Chat").strip()
|
||||
or "WhatsApp Chat",
|
||||
"service_icon_class": _service_icon_class("whatsapp"),
|
||||
"person_name": linked.person.name if linked else "",
|
||||
"compose_page_url": urls["page_url"],
|
||||
"compose_widget_url": urls["widget_url"],
|
||||
"match_url": (
|
||||
f"{reverse('compose_contact_match')}?"
|
||||
f"{urlencode({'service': 'whatsapp', 'identifier': identifier})}"
|
||||
),
|
||||
"last_ts": 0,
|
||||
}
|
||||
)
|
||||
if rows:
|
||||
rows.sort(key=lambda row: row.get("last_ts", 0), reverse=True)
|
||||
return rows
|
||||
@@ -355,8 +405,16 @@ class WhatsAppAccountAdd(SuperUserRequiredMixin, ObjectRead):
|
||||
|
||||
qr_value = str(state.get("pair_qr") or "")
|
||||
contacts = state.get("contacts") or []
|
||||
history_imported = int(state.get("history_imported_messages") or 0)
|
||||
sqlite_imported = int(state.get("history_sqlite_imported") or 0)
|
||||
sqlite_scanned = int(state.get("history_sqlite_scanned") or 0)
|
||||
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_anchor = str(state.get("history_on_demand_anchor") or "").strip() or "-"
|
||||
history_running = bool(state.get("history_sync_running"))
|
||||
return [
|
||||
f"connected={bool(state.get('connected'))}",
|
||||
f"runtime_updated={_age('updated_at')}",
|
||||
f"runtime_seen={_age('runtime_seen_at')}",
|
||||
f"pair_requested={_age('pair_requested_at')}",
|
||||
f"qr_received={_age('qr_received_at')}",
|
||||
@@ -370,6 +428,21 @@ class WhatsAppAccountAdd(SuperUserRequiredMixin, ObjectRead):
|
||||
f"contacts_count={len(contacts) if isinstance(contacts, list) else 0}",
|
||||
f"contacts_sync_count={state.get('contacts_sync_count') or 0}",
|
||||
f"contacts_synced={_age('contacts_synced_at')}",
|
||||
f"history_sync_running={history_running}",
|
||||
f"history_started={_age('history_sync_started_at')}",
|
||||
f"history_finished={_age('history_sync_finished_at')}",
|
||||
f"history_duration_ms={state.get('history_sync_duration_ms') or 0}",
|
||||
f"history_imported_messages={history_imported}",
|
||||
f"history_sqlite_imported={sqlite_imported}",
|
||||
f"history_sqlite_scanned={sqlite_scanned}",
|
||||
f"history_sqlite_rows={state.get('history_sqlite_rows') or 0}",
|
||||
f"history_sqlite_table={state.get('history_sqlite_table') or '-'}",
|
||||
f"history_sqlite_error={state.get('history_sqlite_error') or '-'}",
|
||||
f"history_sqlite_ts={_age('history_sqlite_ts')}",
|
||||
f"history_on_demand_requested={on_demand_requested}",
|
||||
f"history_on_demand_at={_age('history_on_demand_at')}",
|
||||
f"history_on_demand_anchor={on_demand_anchor}",
|
||||
f"history_on_demand_error={on_demand_error}",
|
||||
f"pair_qr_present={bool(qr_value)}",
|
||||
f"session_db={state.get('session_db') or '-'}",
|
||||
]
|
||||
|
||||
@@ -603,6 +603,81 @@ def _resolve_person_identifier(user, person, preferred_service=None):
|
||||
return PersonIdentifier.objects.filter(user=user, person=person).first()
|
||||
|
||||
|
||||
def _send_target_options_for_person(user, person):
|
||||
rows = list(
|
||||
PersonIdentifier.objects.filter(user=user, person=person)
|
||||
.exclude(identifier="")
|
||||
.order_by("service", "identifier", "id")
|
||||
)
|
||||
if not rows:
|
||||
return {"options": [], "selected_id": ""}
|
||||
|
||||
preferred_service = _preferred_service_for_person(user, person)
|
||||
labels = {
|
||||
"signal": "Signal",
|
||||
"whatsapp": "WhatsApp",
|
||||
"instagram": "Instagram",
|
||||
"xmpp": "XMPP",
|
||||
}
|
||||
seen = set()
|
||||
options = []
|
||||
for row in rows:
|
||||
service = str(row.service or "").strip().lower()
|
||||
identifier = str(row.identifier or "").strip()
|
||||
if not service or not identifier:
|
||||
continue
|
||||
dedupe_key = (service, identifier)
|
||||
if dedupe_key in seen:
|
||||
continue
|
||||
seen.add(dedupe_key)
|
||||
options.append(
|
||||
{
|
||||
"id": str(row.id),
|
||||
"service": service,
|
||||
"service_label": labels.get(service, service.title()),
|
||||
"identifier": identifier,
|
||||
}
|
||||
)
|
||||
|
||||
if not options:
|
||||
return {"options": [], "selected_id": ""}
|
||||
|
||||
selected_id = options[0]["id"]
|
||||
if preferred_service:
|
||||
preferred = next(
|
||||
(item for item in options if item["service"] == preferred_service),
|
||||
None,
|
||||
)
|
||||
if preferred is not None:
|
||||
selected_id = preferred["id"]
|
||||
return {"options": options, "selected_id": selected_id}
|
||||
|
||||
|
||||
def _resolve_person_identifier_target(
|
||||
user,
|
||||
person,
|
||||
target_identifier_id="",
|
||||
target_service="",
|
||||
fallback_service=None,
|
||||
):
|
||||
target_id = str(target_identifier_id or "").strip()
|
||||
if target_id:
|
||||
selected = PersonIdentifier.objects.filter(
|
||||
user=user,
|
||||
person=person,
|
||||
id=target_id,
|
||||
).first()
|
||||
if selected is not None:
|
||||
return selected
|
||||
|
||||
preferred = str(target_service or "").strip().lower() or fallback_service
|
||||
return _resolve_person_identifier(
|
||||
user=user,
|
||||
person=person,
|
||||
preferred_service=preferred,
|
||||
)
|
||||
|
||||
|
||||
def _preferred_service_for_person(user, person):
|
||||
"""
|
||||
Best-effort service hint from the most recent workspace conversation.
|
||||
@@ -3314,6 +3389,14 @@ def _mitigation_panel_context(
|
||||
selected_ref = engage_form.get("source_ref") or (
|
||||
engage_options[0]["value"] if engage_options else ""
|
||||
)
|
||||
send_target_bundle = _send_target_options_for_person(plan.user, person)
|
||||
selected_target_id = str(engage_form.get("target_identifier_id") or "").strip()
|
||||
if selected_target_id and not any(
|
||||
item["id"] == selected_target_id for item in send_target_bundle["options"]
|
||||
):
|
||||
selected_target_id = ""
|
||||
if not selected_target_id:
|
||||
selected_target_id = send_target_bundle["selected_id"]
|
||||
auto_settings = auto_settings or _get_or_create_auto_settings(
|
||||
plan.user, plan.conversation
|
||||
)
|
||||
@@ -3340,7 +3423,9 @@ def _mitigation_panel_context(
|
||||
"share_target": engage_form.get("share_target") or "self",
|
||||
"framing": engage_form.get("framing") or "dont_change",
|
||||
"context_note": engage_form.get("context_note") or "",
|
||||
"target_identifier_id": selected_target_id,
|
||||
},
|
||||
"send_target_bundle": send_target_bundle,
|
||||
"send_state": _get_send_state(plan.user, person),
|
||||
"active_tab": _sanitize_active_tab(active_tab),
|
||||
"auto_settings": auto_settings,
|
||||
@@ -3463,12 +3548,15 @@ class AIWorkspacePersonWidget(LoginRequiredMixin, View):
|
||||
],
|
||||
"send_state": _get_send_state(request.user, person),
|
||||
"compose_page_url": _compose_page_url_for_person(request.user, person),
|
||||
"compose_page_base_url": reverse("compose_page"),
|
||||
"compose_widget_url": _compose_widget_url_for_person(
|
||||
request.user,
|
||||
person,
|
||||
limit=limit,
|
||||
),
|
||||
"compose_widget_base_url": reverse("compose_widget"),
|
||||
"manual_icon_class": "fa-solid fa-paper-plane",
|
||||
"send_target_bundle": _send_target_options_for_person(request.user, person),
|
||||
}
|
||||
return render(request, "mixins/wm/widget.html", context)
|
||||
|
||||
@@ -3799,6 +3887,7 @@ class AIWorkspaceRunOperation(LoginRequiredMixin, View):
|
||||
|
||||
person = get_object_or_404(Person, pk=person_id, user=request.user)
|
||||
send_state = _get_send_state(request.user, person)
|
||||
send_target_bundle = _send_target_options_for_person(request.user, person)
|
||||
conversation = _conversation_for_person(request.user, person)
|
||||
|
||||
if operation == "artifacts":
|
||||
@@ -3859,6 +3948,7 @@ class AIWorkspaceRunOperation(LoginRequiredMixin, View):
|
||||
"error": False,
|
||||
"person": person,
|
||||
"send_state": send_state,
|
||||
"send_target_bundle": send_target_bundle,
|
||||
"ai_result_id": "",
|
||||
"mitigation_notice_message": mitigation_notice_message,
|
||||
"mitigation_notice_level": mitigation_notice_level,
|
||||
@@ -3880,6 +3970,7 @@ class AIWorkspaceRunOperation(LoginRequiredMixin, View):
|
||||
"error": True,
|
||||
"person": person,
|
||||
"send_state": send_state,
|
||||
"send_target_bundle": send_target_bundle,
|
||||
"latest_plan": None,
|
||||
"latest_plan_rules": [],
|
||||
"latest_plan_games": [],
|
||||
@@ -4006,6 +4097,7 @@ class AIWorkspaceRunOperation(LoginRequiredMixin, View):
|
||||
"error": False,
|
||||
"person": person,
|
||||
"send_state": send_state,
|
||||
"send_target_bundle": send_target_bundle,
|
||||
"ai_result_id": str(ai_result.id),
|
||||
"ai_result_created_at": ai_result.created_at,
|
||||
"ai_request_status": ai_request.status,
|
||||
@@ -4035,6 +4127,7 @@ class AIWorkspaceRunOperation(LoginRequiredMixin, View):
|
||||
"error": True,
|
||||
"person": person,
|
||||
"send_state": send_state,
|
||||
"send_target_bundle": send_target_bundle,
|
||||
"latest_plan": None,
|
||||
"latest_plan_rules": [],
|
||||
"latest_plan_games": [],
|
||||
@@ -4074,10 +4167,12 @@ class AIWorkspaceSendDraft(LoginRequiredMixin, View):
|
||||
},
|
||||
)
|
||||
|
||||
identifier = _resolve_person_identifier(
|
||||
identifier = _resolve_person_identifier_target(
|
||||
request.user,
|
||||
person,
|
||||
preferred_service=_preferred_service_for_person(request.user, person),
|
||||
target_identifier_id=request.POST.get("target_identifier_id"),
|
||||
target_service=request.POST.get("target_service"),
|
||||
fallback_service=_preferred_service_for_person(request.user, person),
|
||||
)
|
||||
if identifier is None:
|
||||
return render(
|
||||
@@ -4165,10 +4260,12 @@ class AIWorkspaceQueueDraft(LoginRequiredMixin, View):
|
||||
},
|
||||
)
|
||||
|
||||
identifier = _resolve_person_identifier(
|
||||
identifier = _resolve_person_identifier_target(
|
||||
request.user,
|
||||
person,
|
||||
preferred_service=_preferred_service_for_person(request.user, person),
|
||||
target_identifier_id=request.POST.get("target_identifier_id"),
|
||||
target_service=request.POST.get("target_service"),
|
||||
fallback_service=_preferred_service_for_person(request.user, person),
|
||||
)
|
||||
if identifier is None:
|
||||
return render(
|
||||
@@ -4760,6 +4857,9 @@ class AIWorkspaceEngageShare(LoginRequiredMixin, View):
|
||||
"share_target": share_target,
|
||||
"framing": framing,
|
||||
"context_note": context_note,
|
||||
"target_identifier_id": str(
|
||||
request.POST.get("target_identifier_id") or ""
|
||||
).strip(),
|
||||
}
|
||||
active_tab = _sanitize_active_tab(
|
||||
request.POST.get("active_tab"), default="engage"
|
||||
@@ -4856,10 +4956,12 @@ class AIWorkspaceEngageShare(LoginRequiredMixin, View):
|
||||
),
|
||||
)
|
||||
|
||||
identifier = _resolve_person_identifier(
|
||||
identifier = _resolve_person_identifier_target(
|
||||
request.user,
|
||||
person,
|
||||
preferred_service=plan.conversation.platform_type,
|
||||
target_identifier_id=request.POST.get("target_identifier_id"),
|
||||
target_service=request.POST.get("target_service"),
|
||||
fallback_service=plan.conversation.platform_type,
|
||||
)
|
||||
if identifier is None:
|
||||
return render(
|
||||
@@ -4955,10 +5057,12 @@ class AIWorkspaceEngageShare(LoginRequiredMixin, View):
|
||||
return response
|
||||
|
||||
if action == "queue":
|
||||
identifier = _resolve_person_identifier(
|
||||
identifier = _resolve_person_identifier_target(
|
||||
request.user,
|
||||
person,
|
||||
preferred_service=plan.conversation.platform_type,
|
||||
target_identifier_id=request.POST.get("target_identifier_id"),
|
||||
target_service=request.POST.get("target_service"),
|
||||
fallback_service=plan.conversation.platform_type,
|
||||
)
|
||||
if identifier is None:
|
||||
return render(
|
||||
|
||||
@@ -51,7 +51,7 @@ services:
|
||||
# limits:
|
||||
# cpus: '0.1'
|
||||
# memory: 0.25G
|
||||
network_mode: host
|
||||
#network_mode: host
|
||||
|
||||
# giadb:
|
||||
# image: manticoresearch/manticore:dev
|
||||
@@ -74,7 +74,7 @@ services:
|
||||
# - "8080:8080"
|
||||
volumes:
|
||||
- "./signal-cli-config:/home/.local/share/signal-cli"
|
||||
network_mode: host
|
||||
#network_mode: host
|
||||
|
||||
ur:
|
||||
image: xf/gia:prod
|
||||
@@ -127,7 +127,7 @@ services:
|
||||
# limits:
|
||||
# cpus: '0.25'
|
||||
# memory: 0.25G
|
||||
network_mode: host
|
||||
#network_mode: host
|
||||
|
||||
scheduling:
|
||||
image: xf/gia:prod
|
||||
@@ -178,7 +178,7 @@ services:
|
||||
# limits:
|
||||
# cpus: '0.25'
|
||||
# memory: 0.25G
|
||||
network_mode: host
|
||||
#network_mode: host
|
||||
|
||||
migration:
|
||||
image: xf/gia:prod
|
||||
@@ -221,7 +221,7 @@ services:
|
||||
# limits:
|
||||
# cpus: '0.25'
|
||||
# memory: 0.25G
|
||||
network_mode: host
|
||||
#network_mode: host
|
||||
|
||||
collectstatic:
|
||||
image: xf/gia:prod
|
||||
@@ -264,7 +264,7 @@ services:
|
||||
# limits:
|
||||
# cpus: '0.25'
|
||||
# memory: 0.25G
|
||||
network_mode: host
|
||||
#network_mode: host
|
||||
|
||||
redis:
|
||||
image: redis
|
||||
@@ -286,7 +286,7 @@ services:
|
||||
# limits:
|
||||
# cpus: '0.25'
|
||||
# memory: 0.25G
|
||||
network_mode: host
|
||||
#network_mode: host
|
||||
|
||||
volumes:
|
||||
gia_redis_data: {}
|
||||
|
||||
Reference in New Issue
Block a user