Tightly integrate WhatsApp selectors into existing UIs

This commit is contained in:
2026-02-16 10:51:57 +00:00
parent a38339c809
commit 15af8af6b2
19 changed files with 2846 additions and 156 deletions

View File

@@ -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 = [
{

View File

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

View File

@@ -22,12 +22,7 @@ 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_HOST = parsed.hostname or "signal"
SIGNAL_PORT = parsed.port or 8080
else:
if settings.DEBUG:
@@ -276,18 +271,34 @@ 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
identifier_candidates = _identifier_candidates(
primary_identifier,
source_uuid,
source_number,
source_value,
envelope_source_uuid,
envelope_source_number,
envelope_source,
dest,
)
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,
source_number,
source_value,
envelope_source_uuid,
envelope_source_number,
envelope_source,
dest,
)
if not identifier_candidates:
log.warning("No Signal identifier available for message routing.")
return

View File

@@ -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

View File

@@ -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>

View File

@@ -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>

View File

@@ -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 class="table-container">
<table class="table is-fullwidth is-hoverable is-striped">
<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 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,23 +209,41 @@
<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>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% else %}
<p class="has-text-grey">No contacts discovered yet.</p>
@@ -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 %}

View File

@@ -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">

View File

@@ -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>

View File

@@ -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>

View File

@@ -2,18 +2,62 @@
<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>
<p class="is-size-6" style="margin-bottom: 0;">
{% if person %}
{{ person.name }}
{% else %}
{{ identifier }}
{% endif %}
</p>
<p class="is-size-7 compose-meta-line" style="margin-bottom: 0;">
{% 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 }}
{% else %}
{{ identifier }}
{% endif %}
</p>
{% 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 %}
<p class="compose-empty">No stored messages for this contact yet.</p>
<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)) {

View File

@@ -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"

View File

@@ -33,6 +33,7 @@
</a>
{% else %}
<button
type="button"
hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'
hx-get="{{ item.compose_widget_url }}"
hx-trigger="click"

View File

@@ -1,53 +1,231 @@
{% 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 }}">
<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>
<code>{{ item.identifier }}</code>
</td>
<td>{{ item.jid|default:"-" }}</td>
<td>{{ item.person_name|default:"-" }}</td>
<td>
<div class="buttons">
{% if type == 'page' %}
<a href="{{ item.compose_page_url }}" class="button" title="Open manual chat">
<span class="icon"><i class="fa-solid fa-paper-plane"></i></span>
</a>
{% else %}
<button
hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'
hx-get="{{ item.compose_widget_url }}"
hx-trigger="click"
hx-target="#widgets-here"
hx-swap="afterend"
class="button"
title="Open manual chat widget">
<span class="icon"><i class="fa-solid fa-paper-plane"></i></span>
</button>
{% endif %}
<a href="{{ item.match_url }}" class="button" title="Match identifier">
<span class="icon"><i class="fa-solid fa-link"></i></span>
</a>
<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>
</td>
</tr>
{% empty %}
<tr>
<td colspan="5" class="has-text-grey">No WhatsApp contacts discovered yet.</td>
</tr>
{% endfor %}
</table>
<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>
<tr>
<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 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' %}
<a href="{{ item.compose_page_url }}" class="button" title="Open manual chat">
<span class="icon"><i class="fa-solid fa-paper-plane"></i></span>
</a>
{% else %}
<button
type="button"
hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'
hx-get="{{ item.compose_widget_url }}"
hx-trigger="click"
hx-target="#widgets-here"
hx-swap="afterend"
class="button"
title="Open manual chat widget">
<span class="icon"><i class="fa-solid fa-paper-plane"></i></span>
</button>
{% endif %}
<a href="{{ item.match_url }}" class="button" title="Match identifier">
<span class="icon"><i class="fa-solid fa-link"></i></span>
</a>
</div>
</td>
</tr>
{% empty %}
<tr>
<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>

View File

@@ -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,7 +1719,59 @@ def _panel_context(
identifier=base["identifier"],
person_id=base["person"].id if base["person"] else None,
)
ws_url = f"/ws/compose/thread/?{urlencode({'token': ws_token})}"
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.",
],

View File

@@ -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 '-'}",
]

View File

@@ -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(

View File

@@ -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: {}