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" ROOT_URLCONF = "app.urls"
ASGI_APPLICATION = "app.asgi.application" ASGI_APPLICATION = "app.asgi.application"
COMPOSE_WS_ENABLED = os.environ.get("COMPOSE_WS_ENABLED", "false").lower() in {
"1",
"true",
"yes",
"on",
}
TEMPLATES = [ TEMPLATES = [
{ {

View File

@@ -184,6 +184,11 @@ urlpatterns = [
compose.ComposeThread.as_view(), compose.ComposeThread.as_view(),
name="compose_thread", name="compose_thread",
), ),
path(
"compose/history-sync/",
compose.ComposeHistorySync.as_view(),
name="compose_history_sync",
),
path( path(
"compose/media/blob/", "compose/media/blob/",
compose.ComposeMediaBlob.as_view(), compose.ComposeMediaBlob.as_view(),

View File

@@ -22,11 +22,6 @@ if _signal_http_url:
parsed = urlparse( parsed = urlparse(
_signal_http_url if "://" in _signal_http_url else f"http://{_signal_http_url}" _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 SIGNAL_PORT = parsed.port or 8080
else: else:
@@ -276,8 +271,24 @@ class HandleMessage(Command):
envelope_source_uuid = envelope.get("sourceUuid") envelope_source_uuid = envelope.get("sourceUuid")
envelope_source_number = envelope.get("sourceNumber") envelope_source_number = envelope.get("sourceNumber")
envelope_source = envelope.get("source") 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 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( identifier_candidates = _identifier_candidates(
primary_identifier, primary_identifier,
source_uuid, source_uuid,

View File

@@ -19,6 +19,8 @@ from core.util import logs
log = logs.get_logger("transport") log = logs.get_logger("transport")
_RUNTIME_STATE_TTL = 60 * 60 * 24 _RUNTIME_STATE_TTL = 60 * 60 * 24
_RUNTIME_COMMANDS_TTL = 60 * 15
_RUNTIME_COMMAND_RESULT_TTL = 60
_RUNTIME_CLIENTS: dict[str, Any] = {} _RUNTIME_CLIENTS: dict[str, Any] = {}
@@ -30,6 +32,14 @@ def _runtime_key(service: str) -> str:
return f"gia:service:runtime:{_service_key(service)}" 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: def _gateway_base(service: str) -> str:
key = f"{service.upper()}_HTTP_URL" key = f"{service.upper()}_HTTP_URL"
default = f"http://{service}:8080" default = f"http://{service}:8080"
@@ -78,6 +88,59 @@ def update_runtime_state(service: str, **updates):
return state 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): def list_accounts(service: str):
""" """
Return account identifiers for service UI list. 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 return runtime_result
except Exception as exc: except Exception as exc:
log.warning("%s runtime send failed: %s", service_key, 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 return False
if service_key == "instagram": if service_key == "instagram":

File diff suppressed because it is too large Load Diff

View File

@@ -576,6 +576,67 @@
window.giaWindowAnchor = null; 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) { document.addEventListener("click", function (event) {
const trigger = event.target.closest(".js-widget-spawn-trigger"); const trigger = event.target.closest(".js-widget-spawn-trigger");
if (!trigger) { if (!trigger) {
@@ -593,12 +654,13 @@
window.giaEnableWidgetSpawnButtons(target); window.giaEnableWidgetSpawnButtons(target);
const targetId = (target && target.id) || ""; const targetId = (target && target.id) || "";
if (targetId === "windows-here") { if (targetId === "windows-here") {
const floatingWindow = target.querySelector(".floating-window"); const floatingWindows = target.querySelectorAll(".floating-window");
if (floatingWindow) { floatingWindows.forEach(function (floatingWindow) {
window.setTimeout(function () { window.setTimeout(function () {
window.giaPositionFloatingWindow(floatingWindow); window.giaPositionFloatingWindow(floatingWindow);
window.giaEnableFloatingWindowInteractions(floatingWindow);
}, 0); }, 0);
} });
} }
}); });
</script> </script>

View File

@@ -6,6 +6,9 @@
{% block close_button %} {% block close_button %}
{% include "mixins/partials/close-widget.html" %} {% include "mixins/partials/close-widget.html" %}
{% endblock %} {% endblock %}
<span class="icon is-small mr-1">
<i class="{{ widget_icon|default:'fa-solid fa-window-maximize' }}"></i>
</span>
<i <i
class="fa-solid fa-arrows-minimize has-text-grey-light float-right" class="fa-solid fa-arrows-minimize has-text-grey-light float-right"
onclick="grid.compact();"></i> onclick="grid.compact();"></i>

View File

@@ -30,12 +30,12 @@
<div class="column is-5"> <div class="column is-5">
<article class="box"> <article class="box">
<h2 class="title is-6">Create Or Link Identifier</h2> <h2 class="title is-6">Create Or Link Identifier</h2>
<form method="post"> <form id="compose-contact-link-form" method="post">
{% csrf_token %} {% csrf_token %}
<div class="field"> <div class="field">
<label class="label is-small">Service</label> <label class="label is-small">Service</label>
<div class="select is-fullwidth"> <div class="select is-fullwidth">
<select name="service" required> <select id="link-service" name="service" required>
{% for key, label in service_choices %} {% for key, label in service_choices %}
<option value="{{ key }}" {% if key == prefill_service %}selected{% endif %}> <option value="{{ key }}" {% if key == prefill_service %}selected{% endif %}>
{{ label }} {{ label }}
@@ -47,13 +47,13 @@
<div class="field"> <div class="field">
<label class="label is-small">Identifier</label> <label class="label is-small">Identifier</label>
<div class="control"> <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> </div>
<div class="field"> <div class="field">
<label class="label is-small">Existing Person</label> <label class="label is-small">Existing Person</label>
<div class="select is-fullwidth"> <div class="select is-fullwidth">
<select name="person_id"> <select id="link-person-id" name="person_id">
<option value="">- Select person -</option> <option value="">- Select person -</option>
{% for person in people %} {% for person in people %}
<option value="{{ person.id }}">{{ person.name }}</option> <option value="{{ person.id }}">{{ person.name }}</option>
@@ -64,7 +64,7 @@
<div class="field"> <div class="field">
<label class="label is-small">Or Create Person</label> <label class="label is-small">Or Create Person</label>
<div class="control"> <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>
</div> </div>
<button class="button is-link" type="submit"> <button class="button is-link" type="submit">
@@ -79,29 +79,114 @@
<article class="box"> <article class="box">
<h2 class="title is-6">Discovered Contacts</h2> <h2 class="title is-6">Discovered Contacts</h2>
{% if candidates %} {% 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"> <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> <thead>
<tr> <tr>
<th>Person</th> <th data-discovered-col="0" class="discovered-col-0">Person</th>
<th>Detected Name</th> <th data-discovered-col="1" class="discovered-col-1">Name</th>
<th>Service</th> <th data-discovered-col="2" class="discovered-col-2">Service</th>
<th>Identifier</th> <th data-discovered-col="3" class="discovered-col-3">Identifier</th>
<th>Suggested Match</th> <th data-discovered-col="4" class="discovered-col-4">Suggest</th>
<th>Status</th> <th data-discovered-col="5" class="discovered-col-5">Status</th>
<th></th> <th></th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{% for row in candidates %} {% for row in candidates %}
<tr> <tr
<td>{{ row.linked_person_name|default:"-" }}</td> data-service="{{ row.service }}"
<td>{{ row.detected_name|default:"-" }}</td> data-person="{{ row.linked_person_name|default:'-'|lower }}"
<td> 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 }} {{ row.service|title }}
</td> </td>
<td><code>{{ row.identifier }}</code></td> <td data-discovered-col="3" class="discovered-col-3"><code>{{ row.identifier }}</code></td>
<td> <td data-discovered-col="4" class="discovered-col-4">
{% if not row.linked_person and row.suggestions %} {% if not row.linked_person and row.suggestions %}
<div class="buttons are-small"> <div class="buttons are-small">
{% for suggestion in row.suggestions %} {% for suggestion in row.suggestions %}
@@ -124,17 +209,34 @@
<span class="has-text-grey">-</span> <span class="has-text-grey">-</span>
{% endif %} {% endif %}
</td> </td>
<td> <td data-discovered-col="5" class="discovered-col-5">
{% if row.linked_person %} {% if row.linked_person %}
<span class="tag is-success is-light">linked</span> <span class="tag is-success is-light">linked</span>
{% else %} {% else %}
<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 %} {% endif %}
</td> </td>
<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 }}"> <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 class="icon is-small"><i class="fa-solid fa-paper-plane"></i></span>
<span>Message</span>
</a> </a>
</td> </td>
</tr> </tr>
@@ -142,6 +244,7 @@
</tbody> </tbody>
</table> </table>
</div> </div>
</div>
{% else %} {% else %}
<p class="has-text-grey">No contacts discovered yet.</p> <p class="has-text-grey">No contacts discovered yet.</p>
{% endif %} {% endif %}
@@ -151,4 +254,248 @@
</div> </div>
</section> </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 %} {% endblock %}

View File

@@ -112,6 +112,7 @@
hx-swap="innerHTML"> hx-swap="innerHTML">
<input type="hidden" id="draft-send-input-{{ person.id }}-{{ operation }}" name="draft_text" value=""> <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" 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"> <div class="field">
<label class="label is-small">Draft Preview</label> <label class="label is-small">Draft Preview</label>
<div class="control"> <div class="control">

View File

@@ -388,7 +388,23 @@
<input type="hidden" name="active_tab" value="{{ active_tab|default:'engage' }}"> <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-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-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;"> <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;"> <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> <label class="label is-small" style="margin-bottom: 0.25rem;">Source</label>
<div class="select is-small is-fullwidth"> <div class="select is-small is-fullwidth">
@@ -897,6 +913,19 @@
form.requestSubmit(); 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) { window.giaEngageSelect = function(pid, kind, value, node) {
if (pid !== personId) return; if (pid !== personId) return;
let inputId = ""; let inputId = "";
@@ -922,6 +951,14 @@
window.giaMitigationShowTab(personId, "{{ active_tab|default:'plan_board' }}"); window.giaMitigationShowTab(personId, "{{ active_tab|default:'plan_board' }}");
resizeEditableTextareas(document.getElementById("mitigation-shell-" + personId)); 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); window.giaEngageSyncSendOverride(personId);
})(); })();
</script> </script>

View File

@@ -72,12 +72,13 @@
{% endif %} {% endif %}
{% if compose_page_url %} {% if compose_page_url %}
<div class="buttons are-small" style="margin-top: 0.45rem; margin-bottom: 0;"> <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 class="icon is-small"><i class="{{ manual_icon_class }}"></i></span>
<span>Manual Text Mode</span> <span>Manual Text Mode</span>
</a> </a>
{% if compose_widget_url %} {% if compose_widget_url %}
<button <button
id="ai-manual-widget-btn-{{ person.id }}"
type="button" type="button"
class="button is-light is-small js-widget-spawn-trigger is-hidden" class="button is-light is-small js-widget-spawn-trigger is-hidden"
data-widget-url="{{ compose_widget_url }}" data-widget-url="{{ compose_widget_url }}"
@@ -111,6 +112,26 @@
{% endif %} {% endif %}
</div> </div>
</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 id="draft-top-status-{{ person.id }}" style="margin-top: 0.5rem;"></div>
</div> </div>
@@ -719,6 +740,10 @@
} }
const payload = new URLSearchParams(); const payload = new URLSearchParams();
payload.append("draft_text", text); payload.append("draft_text", text);
const targetId = getSelectedTargetId();
if (targetId) {
payload.append("target_identifier_id", targetId);
}
fetch(queueUrl, { fetch(queueUrl, {
method: "POST", method: "POST",
headers: { 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") { if (typeof window.giaMitigationShowTab !== "function") {
window.giaMitigationShowTab = function(pid, tabName) { window.giaMitigationShowTab = function(pid, tabName) {
const names = ["plan_board", "corrections", "engage", "fundamentals", "ask_ai"]; const names = ["plan_board", "corrections", "engage", "fundamentals", "ask_ai"];
@@ -839,5 +932,6 @@
} }
window.giaWorkspaceOpenTab(personId, "plan_board", false); window.giaWorkspaceOpenTab(personId, "plan_board", false);
syncTargetInputs();
})(); })();
</script> </script>

View File

@@ -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 class="is-flex is-justify-content-space-between is-align-items-center" style="gap: 0.5rem; flex-wrap: wrap;">
<div> <div>
<p class="is-size-7 has-text-weight-semibold" style="margin-bottom: 0.2rem;">Manual Text Mode</p> <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;"> <p class="is-size-6" style="margin-bottom: 0;">
{% if person %} {% if person %}
{{ person.name }} {{ person.name }}
@@ -9,11 +29,35 @@
{{ identifier }} {{ identifier }}
{% endif %} {% endif %}
</p> </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 }} {{ service|title }} · {{ identifier }}
</p> </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>
<div class="buttons are-small compose-top-actions" style="margin: 0;"> <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"> <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 class="icon is-small"><i class="fa-solid fa-pen"></i></span>
<span>Drafts</span> <span>Drafts</span>
@@ -182,6 +226,7 @@
data-drafts-url="{{ compose_drafts_url }}" data-drafts-url="{{ compose_drafts_url }}"
data-summary-url="{{ compose_summary_url }}" data-summary-url="{{ compose_summary_url }}"
data-quick-insights-url="{{ compose_quick_insights_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-preview-url="{{ compose_engage_preview_url }}"
data-engage-send-url="{{ compose_engage_send_url }}"> data-engage-send-url="{{ compose_engage_send_url }}">
{% for msg in serialized_messages %} {% for msg in serialized_messages %}
@@ -231,7 +276,13 @@
</article> </article>
</div> </div>
{% empty %} {% empty %}
<div class="compose-empty-wrap">
<p class="compose-empty">No stored messages for this contact yet.</p> <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 %} {% endfor %}
</div> </div>
<p id="{{ panel_id }}-typing" class="compose-typing is-hidden"> <p id="{{ panel_id }}-typing" class="compose-typing is-hidden">
@@ -245,16 +296,14 @@
hx-post="{% url 'compose_send' %}" hx-post="{% url 'compose_send' %}"
hx-target="#{{ panel_id }}-status" hx-target="#{{ panel_id }}-status"
hx-swap="innerHTML"> hx-swap="innerHTML">
<input type="hidden" name="service" value="{{ service }}"> <input id="{{ panel_id }}-input-service" type="hidden" name="service" value="{{ service }}">
<input type="hidden" name="identifier" value="{{ identifier }}"> <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="render_mode" value="{{ render_mode }}">
<input type="hidden" name="limit" value="{{ limit }}"> <input type="hidden" name="limit" value="{{ limit }}">
<input type="hidden" name="panel_id" value="{{ panel_id }}"> <input type="hidden" name="panel_id" value="{{ panel_id }}">
<input type="hidden" name="failsafe_arm" value="0"> <input type="hidden" name="failsafe_arm" value="0">
<input type="hidden" name="failsafe_confirm" 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"> <div class="compose-send-safety">
<label class="checkbox is-size-7"> <label class="checkbox is-size-7">
<input type="checkbox" class="manual-confirm"> Confirm Send <input type="checkbox" class="manual-confirm"> Confirm Send
@@ -477,6 +526,19 @@
#{{ panel_id }} .compose-msg-meta { #{{ panel_id }} .compose-msg-meta {
margin: 0; 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 { #{{ panel_id }} .compose-gap-artifacts {
align-self: center; align-self: center;
width: min(92%, 34rem); width: min(92%, 34rem);
@@ -554,6 +616,16 @@
color: #6f6f6f; color: #6f6f6f;
font-size: 0.78rem; 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 { #{{ panel_id }} .compose-typing {
margin: 0 0 0.5rem 0.2rem; margin: 0 0 0.5rem 0.2rem;
font-size: 0.78rem; font-size: 0.78rem;
@@ -1062,6 +1134,13 @@
const thread = document.getElementById(panelId + "-thread"); const thread = document.getElementById(panelId + "-thread");
const form = document.getElementById(panelId + "-form"); const form = document.getElementById(panelId + "-form");
const textarea = document.getElementById(panelId + "-textarea"); 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) { if (!thread || !form || !textarea) {
return; return;
} }
@@ -1240,9 +1319,9 @@
gap: null, gap: null,
metrics: [], metrics: [],
}; };
const personId = String(thread.dataset.person || "").trim();
const insightUrlForMetric = function (metricSlug) { const insightUrlForMetric = function (metricSlug) {
const slug = String(metricSlug || "").trim(); const slug = String(metricSlug || "").trim();
const personId = String(thread.dataset.person || "").trim();
if (!personId || !slug) { if (!personId || !slug) {
return ""; 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 extractUrlCandidates = function (value) {
const raw = String(value || ""); const raw = String(value || "");
const matches = raw.match(/https?:\/\/[^\s<>'"\\]+/g) || []; const matches = raw.match(/https?:\/\/[^\s<>'"\\]+/g) || [];
@@ -1550,6 +1732,10 @@
if (empty) { if (empty) {
empty.remove(); empty.remove();
} }
const emptyWrap = thread.querySelector(".compose-empty-wrap");
if (emptyWrap) {
emptyWrap.remove();
}
thread.appendChild(row); thread.appendChild(row);
wireImageFallbacks(row); wireImageFallbacks(row);
updateGlanceFromMessage(msg); updateGlanceFromMessage(msg);
@@ -1651,6 +1837,7 @@
lastTs = Math.max(lastTs, toInt(payload.last_ts)); lastTs = Math.max(lastTs, toInt(payload.last_ts));
thread.dataset.lastTs = String(lastTs); thread.dataset.lastTs = String(lastTs);
} }
ensureEmptyState();
} catch (err) { } catch (err) {
console.debug("compose poll error", err); console.debug("compose poll error", err);
} finally { } finally {
@@ -1729,6 +1916,7 @@
// Ignore invalid initial typing state payload. // Ignore invalid initial typing state payload.
} }
applyMinuteGrouping(); applyMinuteGrouping();
bindHistorySyncButtons(panel);
const setStatus = function (message, level) { const setStatus = function (message, level) {
if (!statusBox) { if (!statusBox) {
@@ -1815,6 +2003,78 @@
return params; 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 setCardLoading = function (card, loading) {
const loadingNode = card.querySelector(".compose-ai-loading"); const loadingNode = card.querySelector(".compose-ai-loading");
const contentNode = card.querySelector(".compose-ai-content"); 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) { panelState.docClickHandler = function (event) {
if (!panel.contains(event.target)) { if (!panel.contains(event.target)) {

View File

@@ -62,6 +62,7 @@
</a> </a>
{% else %} {% else %}
<button <button
type="button"
hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}' hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'
hx-get="{% url contacts_url_name type=type pk=item %}" hx-get="{% url contacts_url_name type=type pk=item %}"
hx-trigger="click" hx-trigger="click"
@@ -75,6 +76,7 @@
</span> </span>
</button> </button>
<button <button
type="button"
hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}' hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'
hx-get="{% url chats_url_name type=type pk=item %}" hx-get="{% url chats_url_name type=type pk=item %}"
hx-trigger="click" hx-trigger="click"

View File

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

View File

@@ -1,26 +1,92 @@
{% include 'mixins/partials/notify.html' %} {% include 'mixins/partials/notify.html' %}
<table <div
class="table is-fullwidth is-hoverable" id="whatsapp-contacts-shell"
hx-target="#{{ context_object_name }}-table" hx-target="#whatsapp-contacts-shell"
id="{{ context_object_name }}-table"
hx-swap="outerHTML" hx-swap="outerHTML"
hx-trigger="{{ context_object_name_singular }}Event from:body" hx-trigger="{{ context_object_name_singular }}Event from:body"
hx-get="{{ list_url }}"> 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> <thead>
<th>name</th>
<th>identifier</th>
<th>jid</th>
<th>person</th>
<th>actions</th>
</thead>
{% for item in object_list %}
<tr> <tr>
<td>{{ item.name|default:"-" }}</td> <th data-whatsapp-col="0" class="whatsapp-col-0">name</th>
<td> <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> <code>{{ item.identifier }}</code>
</td> </td>
<td>{{ item.jid|default:"-" }}</td> <td data-whatsapp-col="2" class="whatsapp-col-2">{{ item.jid|default:"-" }}</td>
<td>{{ item.person_name|default:"-" }}</td> <td data-whatsapp-col="3" class="whatsapp-col-3">{{ item.person_name|default:"-" }}</td>
<td> <td>
<div class="buttons"> <div class="buttons">
{% if type == 'page' %} {% if type == 'page' %}
@@ -29,6 +95,7 @@
</a> </a>
{% else %} {% else %}
<button <button
type="button"
hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}' hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'
hx-get="{{ item.compose_widget_url }}" hx-get="{{ item.compose_widget_url }}"
hx-trigger="click" hx-trigger="click"
@@ -50,4 +117,115 @@
<td colspan="5" class="has-text-grey">No WhatsApp contacts discovered yet.</td> <td colspan="5" class="has-text-grey">No WhatsApp contacts discovered yet.</td>
</tr> </tr>
{% endfor %} {% endfor %}
</tbody>
</table> </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_ENGAGE_TOKEN_SALT = "compose-engage"
COMPOSE_AI_CACHE_TTL = 60 * 30 COMPOSE_AI_CACHE_TTL = 60 * 30
URL_PATTERN = re.compile(r"https?://[^\s<>'\"\\]+") 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 = ( IMAGE_EXTENSIONS = (
".png", ".png",
".jpg", ".jpg",
@@ -1349,6 +1353,42 @@ def _service_icon_class(service: str) -> str:
return "fa-solid fa-address-card" 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): def _manual_contact_rows(user):
rows = [] rows = []
seen = set() seen = set()
@@ -1397,6 +1437,7 @@ def _manual_contact_rows(user):
{ {
"person_name": person_name, "person_name": person_name,
"linked_person_name": linked_person_name, "linked_person_name": linked_person_name,
"person_id": str(person.id) if person else "",
"detected_name": detected, "detected_name": detected,
"service": service_key, "service": service_key,
"service_icon_class": _service_icon_class(service_key), "service_icon_class": _service_icon_class(service_key),
@@ -1489,7 +1530,94 @@ def _manual_contact_rows(user):
detected_name=detected_name, 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 return rows
@@ -1591,8 +1719,60 @@ def _panel_context(
identifier=base["identifier"], identifier=base["identifier"],
person_id=base["person"].id if base["person"] else None, 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})}" 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 = ( unique_raw = (
f"{base['service']}|{base['identifier']}|{request.user.id}|{time.time_ns()}" f"{base['service']}|{base['identifier']}|{request.user.id}|{time.time_ns()}"
) )
@@ -1601,6 +1781,13 @@ def _panel_context(
user_id=request.user.id, user_id=request.user.id,
person_id=base["person"].id if base["person"] else None, 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 { return {
"service": base["service"], "service": base["service"],
@@ -1627,6 +1814,7 @@ def _panel_context(
"compose_engage_preview_url": reverse("compose_engage_preview"), "compose_engage_preview_url": reverse("compose_engage_preview"),
"compose_engage_send_url": reverse("compose_engage_send"), "compose_engage_send_url": reverse("compose_engage_send"),
"compose_quick_insights_url": reverse("compose_quick_insights"), "compose_quick_insights_url": reverse("compose_quick_insights"),
"compose_history_sync_url": reverse("compose_history_sync"),
"compose_ws_url": ws_url, "compose_ws_url": ws_url,
"ai_workspace_url": ( "ai_workspace_url": (
f"{reverse('ai_workspace')}?person={base['person'].id}" f"{reverse('ai_workspace')}?person={base['person'].id}"
@@ -1644,6 +1832,8 @@ def _panel_context(
"manual_icon_class": "fa-solid fa-paper-plane", "manual_icon_class": "fa-solid fa-paper-plane",
"panel_id": f"compose-panel-{unique}", "panel_id": f"compose-panel-{unique}",
"typing_state_json": json.dumps(typing_state), "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): def get(self, request):
return render(request, self.template_name, self._context(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): def post(self, request):
person_id = str(request.POST.get("person_id") or "").strip() person_id = str(request.POST.get("person_id") or "").strip()
person_name = str(request.POST.get("person_name") 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}." message = f"Re-linked {identifier} ({service}) to {person.name}."
else: else:
message = f"{identifier} ({service}) is already linked to {person.name}." message = f"{identifier} ({service}) is already linked to {person.name}."
linked_companions = 0
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( return render(
request, request,
self.template_name, self.template_name,
@@ -1880,12 +2124,24 @@ class ComposeThread(LoginRequiredMixin, View):
latest_ts = after_ts latest_ts = after_ts
messages = [] messages = []
seed_previous = None 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: if base["person_identifier"] is not None:
session, _ = ChatSession.objects.get_or_create( session, _ = ChatSession.objects.get_or_create(
user=request.user, user=request.user,
identifier=base["person_identifier"], 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 queryset = base_queryset
if after_ts > 0: if after_ts > 0:
seed_previous = ( seed_previous = (
@@ -1901,7 +2157,10 @@ class ComposeThread(LoginRequiredMixin, View):
.order_by("ts")[:limit] .order_by("ts")[:limit]
) )
newest = ( newest = (
Message.objects.filter(user=request.user, session=session) Message.objects.filter(
user=request.user,
session_id__in=session_ids,
)
.order_by("-ts") .order_by("-ts")
.values_list("ts", flat=True) .values_list("ts", flat=True)
.first() .first()
@@ -1928,6 +2187,284 @@ class ComposeThread(LoginRequiredMixin, View):
return JsonResponse(payload) 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): class ComposeMediaBlob(LoginRequiredMixin, View):
""" """
Serve cached media blobs for authenticated compose image previews. Serve cached media blobs for authenticated compose image previews.
@@ -2151,13 +2688,15 @@ class ComposeQuickInsights(LoginRequiredMixin, View):
payload = _quick_insights_rows(conversation) payload = _quick_insights_rows(conversation)
participant_state = _participant_feedback_state_label(conversation, person) participant_state = _participant_feedback_state_label(conversation, person)
selected_platform_label = _service_label(base["service"])
return JsonResponse( return JsonResponse(
{ {
"ok": True, "ok": True,
"empty": False, "empty": False,
"summary": { "summary": {
"person_name": person.name, "person_name": person.name,
"platform": conversation.get_platform_type_display(), "platform": selected_platform_label,
"platform_scope": "All linked platforms",
"state": participant_state "state": participant_state
or conversation.get_stability_state_display(), or conversation.get_stability_state_display(),
"stability_state": 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.", "Each row shows current value, percent change vs previous point, and data-point count.",
"Arrow color indicates improving or risk direction for that metric.", "Arrow color indicates improving or risk direction for that metric.",
"State uses participant feedback (Withdrawing/Overextending/Balanced) when available.", "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.", "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.", "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): def get_queryset(self, *args, **kwargs):
rows = [] 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 = ( sessions = (
ChatSession.objects.filter( ChatSession.objects.filter(
user=self.request.user, user=self.request.user,
@@ -273,8 +285,9 @@ class WhatsAppChatsList(WhatsAppContactsList):
) )
for session in sessions: for session in sessions:
identifier = str(session.identifier.identifier or "").strip() identifier = str(session.identifier.identifier or "").strip()
if not identifier: if not identifier or identifier in seen:
continue continue
seen.add(identifier)
latest = ( latest = (
Message.objects.filter(user=self.request.user, session=session) Message.objects.filter(user=self.request.user, session=session)
.order_by("-ts") .order_by("-ts")
@@ -284,15 +297,17 @@ class WhatsAppChatsList(WhatsAppContactsList):
preview = str((latest.text if latest else "") or "").strip() preview = str((latest.text if latest else "") or "").strip()
if len(preview) > 80: if len(preview) > 80:
preview = f"{preview[:77]}..." preview = f"{preview[:77]}..."
display_name = (
preview
or runtime_name_map.get(identifier)
or session.identifier.person.name
or "WhatsApp Chat"
)
rows.append( rows.append(
{ {
"identifier": identifier, "identifier": identifier,
"jid": identifier, "jid": identifier,
"name": ( "name": display_name,
preview
or session.identifier.person.name
or "WhatsApp Chat"
),
"service_icon_class": _service_icon_class("whatsapp"), "service_icon_class": _service_icon_class("whatsapp"),
"person_name": session.identifier.person.name, "person_name": session.identifier.person.name,
"compose_page_url": urls["page_url"], "compose_page_url": urls["page_url"],
@@ -304,6 +319,41 @@ class WhatsAppChatsList(WhatsAppContactsList):
"last_ts": int(latest.ts or 0) if latest else 0, "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: if rows:
rows.sort(key=lambda row: row.get("last_ts", 0), reverse=True) rows.sort(key=lambda row: row.get("last_ts", 0), reverse=True)
return rows return rows
@@ -355,8 +405,16 @@ class WhatsAppAccountAdd(SuperUserRequiredMixin, ObjectRead):
qr_value = str(state.get("pair_qr") or "") qr_value = str(state.get("pair_qr") or "")
contacts = state.get("contacts") 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 [ return [
f"connected={bool(state.get('connected'))}", f"connected={bool(state.get('connected'))}",
f"runtime_updated={_age('updated_at')}",
f"runtime_seen={_age('runtime_seen_at')}", f"runtime_seen={_age('runtime_seen_at')}",
f"pair_requested={_age('pair_requested_at')}", f"pair_requested={_age('pair_requested_at')}",
f"qr_received={_age('qr_received_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_count={len(contacts) if isinstance(contacts, list) else 0}",
f"contacts_sync_count={state.get('contacts_sync_count') or 0}", f"contacts_sync_count={state.get('contacts_sync_count') or 0}",
f"contacts_synced={_age('contacts_synced_at')}", 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"pair_qr_present={bool(qr_value)}",
f"session_db={state.get('session_db') or '-'}", 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() 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): def _preferred_service_for_person(user, person):
""" """
Best-effort service hint from the most recent workspace conversation. 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 ( selected_ref = engage_form.get("source_ref") or (
engage_options[0]["value"] if engage_options else "" 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( auto_settings = auto_settings or _get_or_create_auto_settings(
plan.user, plan.conversation plan.user, plan.conversation
) )
@@ -3340,7 +3423,9 @@ def _mitigation_panel_context(
"share_target": engage_form.get("share_target") or "self", "share_target": engage_form.get("share_target") or "self",
"framing": engage_form.get("framing") or "dont_change", "framing": engage_form.get("framing") or "dont_change",
"context_note": engage_form.get("context_note") or "", "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), "send_state": _get_send_state(plan.user, person),
"active_tab": _sanitize_active_tab(active_tab), "active_tab": _sanitize_active_tab(active_tab),
"auto_settings": auto_settings, "auto_settings": auto_settings,
@@ -3463,12 +3548,15 @@ class AIWorkspacePersonWidget(LoginRequiredMixin, View):
], ],
"send_state": _get_send_state(request.user, person), "send_state": _get_send_state(request.user, person),
"compose_page_url": _compose_page_url_for_person(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( "compose_widget_url": _compose_widget_url_for_person(
request.user, request.user,
person, person,
limit=limit, limit=limit,
), ),
"compose_widget_base_url": reverse("compose_widget"),
"manual_icon_class": "fa-solid fa-paper-plane", "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) 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) person = get_object_or_404(Person, pk=person_id, user=request.user)
send_state = _get_send_state(request.user, person) 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) conversation = _conversation_for_person(request.user, person)
if operation == "artifacts": if operation == "artifacts":
@@ -3859,6 +3948,7 @@ class AIWorkspaceRunOperation(LoginRequiredMixin, View):
"error": False, "error": False,
"person": person, "person": person,
"send_state": send_state, "send_state": send_state,
"send_target_bundle": send_target_bundle,
"ai_result_id": "", "ai_result_id": "",
"mitigation_notice_message": mitigation_notice_message, "mitigation_notice_message": mitigation_notice_message,
"mitigation_notice_level": mitigation_notice_level, "mitigation_notice_level": mitigation_notice_level,
@@ -3880,6 +3970,7 @@ class AIWorkspaceRunOperation(LoginRequiredMixin, View):
"error": True, "error": True,
"person": person, "person": person,
"send_state": send_state, "send_state": send_state,
"send_target_bundle": send_target_bundle,
"latest_plan": None, "latest_plan": None,
"latest_plan_rules": [], "latest_plan_rules": [],
"latest_plan_games": [], "latest_plan_games": [],
@@ -4006,6 +4097,7 @@ class AIWorkspaceRunOperation(LoginRequiredMixin, View):
"error": False, "error": False,
"person": person, "person": person,
"send_state": send_state, "send_state": send_state,
"send_target_bundle": send_target_bundle,
"ai_result_id": str(ai_result.id), "ai_result_id": str(ai_result.id),
"ai_result_created_at": ai_result.created_at, "ai_result_created_at": ai_result.created_at,
"ai_request_status": ai_request.status, "ai_request_status": ai_request.status,
@@ -4035,6 +4127,7 @@ class AIWorkspaceRunOperation(LoginRequiredMixin, View):
"error": True, "error": True,
"person": person, "person": person,
"send_state": send_state, "send_state": send_state,
"send_target_bundle": send_target_bundle,
"latest_plan": None, "latest_plan": None,
"latest_plan_rules": [], "latest_plan_rules": [],
"latest_plan_games": [], "latest_plan_games": [],
@@ -4074,10 +4167,12 @@ class AIWorkspaceSendDraft(LoginRequiredMixin, View):
}, },
) )
identifier = _resolve_person_identifier( identifier = _resolve_person_identifier_target(
request.user, request.user,
person, 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: if identifier is None:
return render( return render(
@@ -4165,10 +4260,12 @@ class AIWorkspaceQueueDraft(LoginRequiredMixin, View):
}, },
) )
identifier = _resolve_person_identifier( identifier = _resolve_person_identifier_target(
request.user, request.user,
person, 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: if identifier is None:
return render( return render(
@@ -4760,6 +4857,9 @@ class AIWorkspaceEngageShare(LoginRequiredMixin, View):
"share_target": share_target, "share_target": share_target,
"framing": framing, "framing": framing,
"context_note": context_note, "context_note": context_note,
"target_identifier_id": str(
request.POST.get("target_identifier_id") or ""
).strip(),
} }
active_tab = _sanitize_active_tab( active_tab = _sanitize_active_tab(
request.POST.get("active_tab"), default="engage" 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, request.user,
person, 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: if identifier is None:
return render( return render(
@@ -4955,10 +5057,12 @@ class AIWorkspaceEngageShare(LoginRequiredMixin, View):
return response return response
if action == "queue": if action == "queue":
identifier = _resolve_person_identifier( identifier = _resolve_person_identifier_target(
request.user, request.user,
person, 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: if identifier is None:
return render( return render(

View File

@@ -51,7 +51,7 @@ services:
# limits: # limits:
# cpus: '0.1' # cpus: '0.1'
# memory: 0.25G # memory: 0.25G
network_mode: host #network_mode: host
# giadb: # giadb:
# image: manticoresearch/manticore:dev # image: manticoresearch/manticore:dev
@@ -74,7 +74,7 @@ services:
# - "8080:8080" # - "8080:8080"
volumes: volumes:
- "./signal-cli-config:/home/.local/share/signal-cli" - "./signal-cli-config:/home/.local/share/signal-cli"
network_mode: host #network_mode: host
ur: ur:
image: xf/gia:prod image: xf/gia:prod
@@ -127,7 +127,7 @@ services:
# limits: # limits:
# cpus: '0.25' # cpus: '0.25'
# memory: 0.25G # memory: 0.25G
network_mode: host #network_mode: host
scheduling: scheduling:
image: xf/gia:prod image: xf/gia:prod
@@ -178,7 +178,7 @@ services:
# limits: # limits:
# cpus: '0.25' # cpus: '0.25'
# memory: 0.25G # memory: 0.25G
network_mode: host #network_mode: host
migration: migration:
image: xf/gia:prod image: xf/gia:prod
@@ -221,7 +221,7 @@ services:
# limits: # limits:
# cpus: '0.25' # cpus: '0.25'
# memory: 0.25G # memory: 0.25G
network_mode: host #network_mode: host
collectstatic: collectstatic:
image: xf/gia:prod image: xf/gia:prod
@@ -264,7 +264,7 @@ services:
# limits: # limits:
# cpus: '0.25' # cpus: '0.25'
# memory: 0.25G # memory: 0.25G
network_mode: host #network_mode: host
redis: redis:
image: redis image: redis
@@ -286,7 +286,7 @@ services:
# limits: # limits:
# cpus: '0.25' # cpus: '0.25'
# memory: 0.25G # memory: 0.25G
network_mode: host #network_mode: host
volumes: volumes:
gia_redis_data: {} gia_redis_data: {}