Implement contact matching
This commit is contained in:
15
app/urls.py
15
app/urls.py
@@ -89,11 +89,21 @@ urlpatterns = [
|
|||||||
signal.SignalContactsList.as_view(),
|
signal.SignalContactsList.as_view(),
|
||||||
name="signal_contacts",
|
name="signal_contacts",
|
||||||
),
|
),
|
||||||
|
path(
|
||||||
|
"services/whatsapp/<str:type>/contacts/<str:pk>/",
|
||||||
|
whatsapp.WhatsAppContactsList.as_view(),
|
||||||
|
name="whatsapp_contacts",
|
||||||
|
),
|
||||||
path(
|
path(
|
||||||
"services/signal/<str:type>/chats/<str:pk>/",
|
"services/signal/<str:type>/chats/<str:pk>/",
|
||||||
signal.SignalChatsList.as_view(),
|
signal.SignalChatsList.as_view(),
|
||||||
name="signal_chats",
|
name="signal_chats",
|
||||||
),
|
),
|
||||||
|
path(
|
||||||
|
"services/whatsapp/<str:type>/chats/<str:pk>/",
|
||||||
|
whatsapp.WhatsAppChatsList.as_view(),
|
||||||
|
name="whatsapp_chats",
|
||||||
|
),
|
||||||
path(
|
path(
|
||||||
"services/signal/<str:type>/messages/<str:pk>/<str:chat_id>/",
|
"services/signal/<str:type>/messages/<str:pk>/<str:chat_id>/",
|
||||||
signal.SignalMessagesList.as_view(),
|
signal.SignalMessagesList.as_view(),
|
||||||
@@ -179,6 +189,11 @@ urlpatterns = [
|
|||||||
compose.ComposeContactsDropdown.as_view(),
|
compose.ComposeContactsDropdown.as_view(),
|
||||||
name="compose_contacts_dropdown",
|
name="compose_contacts_dropdown",
|
||||||
),
|
),
|
||||||
|
path(
|
||||||
|
"compose/contacts/match/",
|
||||||
|
compose.ComposeContactMatch.as_view(),
|
||||||
|
name="compose_contact_match",
|
||||||
|
),
|
||||||
# AIs
|
# AIs
|
||||||
path(
|
path(
|
||||||
"ai/workspace/",
|
"ai/workspace/",
|
||||||
|
|||||||
@@ -262,8 +262,13 @@ class WhatsAppClient(ClientBase):
|
|||||||
)
|
)
|
||||||
if connected_value:
|
if connected_value:
|
||||||
self._connected = True
|
self._connected = True
|
||||||
|
account = await self._resolve_account_identifier()
|
||||||
|
account_list = [account] if account else list(self._accounts or [])
|
||||||
|
if not account_list:
|
||||||
|
account_list = [self.client_name]
|
||||||
self._publish_state(
|
self._publish_state(
|
||||||
connected=True,
|
connected=True,
|
||||||
|
accounts=account_list,
|
||||||
pair_status="connected",
|
pair_status="connected",
|
||||||
last_event="connected_probe",
|
last_event="connected_probe",
|
||||||
connected_at=now_ts,
|
connected_at=now_ts,
|
||||||
@@ -442,6 +447,12 @@ class WhatsAppClient(ClientBase):
|
|||||||
async def on_connected(client, event: connected_ev):
|
async def on_connected(client, event: connected_ev):
|
||||||
self._connected = True
|
self._connected = True
|
||||||
account = await self._resolve_account_identifier()
|
account = await self._resolve_account_identifier()
|
||||||
|
if account:
|
||||||
|
self._remember_contact(
|
||||||
|
account,
|
||||||
|
jid=account,
|
||||||
|
name="Linked Account",
|
||||||
|
)
|
||||||
self._publish_state(
|
self._publish_state(
|
||||||
connected=True,
|
connected=True,
|
||||||
warning="",
|
warning="",
|
||||||
@@ -502,6 +513,12 @@ class WhatsAppClient(ClientBase):
|
|||||||
status_text = str(status_raw or "").strip().lower()
|
status_text = str(status_raw or "").strip().lower()
|
||||||
if status_text in {"2", "success"}:
|
if status_text in {"2", "success"}:
|
||||||
account = await self._resolve_account_identifier()
|
account = await self._resolve_account_identifier()
|
||||||
|
if account:
|
||||||
|
self._remember_contact(
|
||||||
|
account,
|
||||||
|
jid=account,
|
||||||
|
name="Linked Account",
|
||||||
|
)
|
||||||
self._connected = True
|
self._connected = True
|
||||||
self._publish_state(
|
self._publish_state(
|
||||||
connected=True,
|
connected=True,
|
||||||
@@ -640,6 +657,35 @@ class WhatsAppClient(ClientBase):
|
|||||||
out.add(f"+{digits}")
|
out.add(f"+{digits}")
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
def _remember_contact(self, identifier, *, jid="", name="", chat=""):
|
||||||
|
cleaned = str(identifier or "").strip()
|
||||||
|
if not cleaned:
|
||||||
|
return
|
||||||
|
state = transport.get_runtime_state(self.service)
|
||||||
|
existing = state.get("contacts") or []
|
||||||
|
rows = [item for item in existing if isinstance(item, dict)]
|
||||||
|
merged = []
|
||||||
|
seen = set()
|
||||||
|
now_ts = int(time.time())
|
||||||
|
row = {
|
||||||
|
"identifier": cleaned,
|
||||||
|
"jid": str(jid or "").strip(),
|
||||||
|
"name": str(name or "").strip(),
|
||||||
|
"chat": str(chat or "").strip(),
|
||||||
|
"seen_at": now_ts,
|
||||||
|
}
|
||||||
|
merged.append(row)
|
||||||
|
seen.add(cleaned)
|
||||||
|
for item in rows:
|
||||||
|
candidate = str(item.get("identifier") or item.get("jid") or "").strip()
|
||||||
|
if not candidate or candidate in seen:
|
||||||
|
continue
|
||||||
|
seen.add(candidate)
|
||||||
|
merged.append(item)
|
||||||
|
if len(merged) >= 500:
|
||||||
|
break
|
||||||
|
self._publish_state(contacts=merged, last_contact_seen_at=now_ts)
|
||||||
|
|
||||||
def _jid_to_identifier(self, value):
|
def _jid_to_identifier(self, value):
|
||||||
raw = str(value or "").strip()
|
raw = str(value or "").strip()
|
||||||
if not raw:
|
if not raw:
|
||||||
@@ -761,6 +807,11 @@ class WhatsAppClient(ClientBase):
|
|||||||
or self._pluck(event, "timestamp")
|
or self._pluck(event, "timestamp")
|
||||||
)
|
)
|
||||||
ts = self._normalize_timestamp(raw_ts)
|
ts = self._normalize_timestamp(raw_ts)
|
||||||
|
self._remember_contact(
|
||||||
|
sender or chat,
|
||||||
|
jid=sender,
|
||||||
|
chat=chat,
|
||||||
|
)
|
||||||
|
|
||||||
identifier_values = self._normalize_identifier_candidates(sender, chat)
|
identifier_values = self._normalize_identifier_candidates(sender, chat)
|
||||||
if not identifier_values:
|
if not identifier_values:
|
||||||
@@ -853,6 +904,11 @@ class WhatsAppClient(ClientBase):
|
|||||||
or int(time.time() * 1000)
|
or int(time.time() * 1000)
|
||||||
)
|
)
|
||||||
receipt_type = str(self._pluck(event, "Type") or "").strip()
|
receipt_type = str(self._pluck(event, "Type") or "").strip()
|
||||||
|
self._remember_contact(
|
||||||
|
sender or chat,
|
||||||
|
jid=sender,
|
||||||
|
chat=chat,
|
||||||
|
)
|
||||||
|
|
||||||
for candidate in self._normalize_identifier_candidates(sender, chat):
|
for candidate in self._normalize_identifier_candidates(sender, chat):
|
||||||
await self.ur.message_read(
|
await self.ur.message_read(
|
||||||
@@ -885,6 +941,11 @@ class WhatsAppClient(ClientBase):
|
|||||||
state = self._pluck(event, "State") or self._pluck(event, "state")
|
state = self._pluck(event, "State") or self._pluck(event, "state")
|
||||||
state_text = str(state or "").strip().lower()
|
state_text = str(state or "").strip().lower()
|
||||||
is_typing = state_text in {"1", "composing", "chat_presence_composing"}
|
is_typing = state_text in {"1", "composing", "chat_presence_composing"}
|
||||||
|
self._remember_contact(
|
||||||
|
sender or chat,
|
||||||
|
jid=sender,
|
||||||
|
chat=chat,
|
||||||
|
)
|
||||||
|
|
||||||
for candidate in self._normalize_identifier_candidates(sender, chat):
|
for candidate in self._normalize_identifier_candidates(sender, chat):
|
||||||
if is_typing:
|
if is_typing:
|
||||||
@@ -908,6 +969,7 @@ class WhatsAppClient(ClientBase):
|
|||||||
is_unavailable = bool(
|
is_unavailable = bool(
|
||||||
self._pluck(event, "Unavailable") or self._pluck(event, "unavailable")
|
self._pluck(event, "Unavailable") or self._pluck(event, "unavailable")
|
||||||
)
|
)
|
||||||
|
self._remember_contact(sender, jid=sender)
|
||||||
|
|
||||||
for candidate in self._normalize_identifier_candidates(sender):
|
for candidate in self._normalize_identifier_candidates(sender):
|
||||||
if is_unavailable:
|
if is_unavailable:
|
||||||
|
|||||||
129
core/templates/pages/compose-contact-match.html
Normal file
129
core/templates/pages/compose-contact-match.html
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
{% extends "index.html" %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<section class="section">
|
||||||
|
<div class="container">
|
||||||
|
<div class="level" style="margin-bottom: 0.75rem;">
|
||||||
|
<div class="level-left">
|
||||||
|
<div>
|
||||||
|
<h1 class="title is-4" style="margin-bottom: 0.2rem;">Contact Match</h1>
|
||||||
|
<p class="is-size-7 has-text-grey">
|
||||||
|
Manually link Signal, WhatsApp, Instagram, and XMPP identifiers to people.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="level-right">
|
||||||
|
<a class="button is-light" href="{% url 'compose_workspace' %}">
|
||||||
|
<span class="icon is-small"><i class="fa-solid fa-table-cells-large"></i></span>
|
||||||
|
<span>Manual Workspace</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if notice_message %}
|
||||||
|
<article class="notification is-{{ notice_level|default:'info' }} is-light">
|
||||||
|
{{ notice_message }}
|
||||||
|
</article>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="columns is-variable is-4">
|
||||||
|
<div class="column is-5">
|
||||||
|
<article class="box">
|
||||||
|
<h2 class="title is-6">Create Or Link Identifier</h2>
|
||||||
|
<form method="post">
|
||||||
|
{% csrf_token %}
|
||||||
|
<div class="field">
|
||||||
|
<label class="label is-small">Service</label>
|
||||||
|
<div class="select is-fullwidth">
|
||||||
|
<select name="service" required>
|
||||||
|
{% for key, label in service_choices %}
|
||||||
|
<option value="{{ key }}" {% if key == prefill_service %}selected{% endif %}>
|
||||||
|
{{ label }}
|
||||||
|
</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label class="label is-small">Identifier</label>
|
||||||
|
<div class="control">
|
||||||
|
<input class="input" type="text" name="identifier" value="{{ prefill_identifier }}" required>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label class="label is-small">Existing Person</label>
|
||||||
|
<div class="select is-fullwidth">
|
||||||
|
<select name="person_id">
|
||||||
|
<option value="">- Select person -</option>
|
||||||
|
{% for person in people %}
|
||||||
|
<option value="{{ person.id }}">{{ person.name }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label class="label is-small">Or Create Person</label>
|
||||||
|
<div class="control">
|
||||||
|
<input class="input" type="text" name="person_name" placeholder="New person name">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button class="button is-link" type="submit">
|
||||||
|
<span class="icon is-small"><i class="fa-solid fa-link"></i></span>
|
||||||
|
<span>Save Match</span>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="column is-7">
|
||||||
|
<article class="box">
|
||||||
|
<h2 class="title is-6">Discovered Contacts</h2>
|
||||||
|
{% if candidates %}
|
||||||
|
<div class="table-container">
|
||||||
|
<table class="table is-fullwidth is-hoverable is-striped">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Contact</th>
|
||||||
|
<th>Service</th>
|
||||||
|
<th>Identifier</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for row in candidates %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ row.person_name }}</td>
|
||||||
|
<td>
|
||||||
|
<span class="icon is-small"><i class="{{ row.service_icon_class }}"></i></span>
|
||||||
|
{{ row.service|title }}
|
||||||
|
</td>
|
||||||
|
<td><code>{{ row.identifier }}</code></td>
|
||||||
|
<td>
|
||||||
|
{% if row.linked_person %}
|
||||||
|
<span class="tag is-success is-light">linked</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="tag is-warning is-light">unlinked</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<a class="button is-small is-light" href="{{ row.compose_url }}">
|
||||||
|
<span class="icon is-small"><i class="fa-solid fa-paper-plane"></i></span>
|
||||||
|
<span>Message</span>
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<p class="has-text-grey">No contacts discovered yet.</p>
|
||||||
|
{% endif %}
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
@@ -64,7 +64,7 @@
|
|||||||
<span
|
<span
|
||||||
class="tag is-dark"
|
class="tag is-dark"
|
||||||
style="min-width: 2.5rem; justify-content: center;">
|
style="min-width: 2.5rem; justify-content: center;">
|
||||||
<i class="{{ manual_icon_class }}" aria-hidden="true"></i>
|
<i class="{{ row.service_icon_class|default:manual_icon_class }}" aria-hidden="true"></i>
|
||||||
</span>
|
</span>
|
||||||
<span
|
<span
|
||||||
class="tag is-white"
|
class="tag is-white"
|
||||||
@@ -103,6 +103,15 @@
|
|||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
{% if not row.linked_person %}
|
||||||
|
<a
|
||||||
|
class="button is-small is-light"
|
||||||
|
href="{{ row.match_url }}"
|
||||||
|
title="Link this identifier to a person">
|
||||||
|
<span class="icon is-small"><i class="fa-solid fa-link"></i></span>
|
||||||
|
<span>Match</span>
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
|
|||||||
@@ -1,15 +1,23 @@
|
|||||||
{% if items %}
|
{% if items %}
|
||||||
{% for item in items %}
|
{% for item in items %}
|
||||||
<a class="navbar-item" href="{{ item.compose_url }}">
|
<a class="navbar-item" href="{{ item.compose_url }}">
|
||||||
<span class="icon is-small"><i class="{{ manual_icon_class }}"></i></span>
|
<span class="icon is-small"><i class="{{ item.service_icon_class|default:manual_icon_class }}"></i></span>
|
||||||
<span style="margin-left: 0.35rem;">
|
<span style="margin-left: 0.35rem;">
|
||||||
{{ item.person_name }} · {{ item.service|title }}
|
{{ item.person_name }} · {{ item.service|title }}
|
||||||
|
{% if not item.linked_person %}
|
||||||
|
<small class="has-text-grey"> · unlinked</small>
|
||||||
|
{% endif %}
|
||||||
</span>
|
</span>
|
||||||
</a>
|
</a>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
{% else %}
|
{% else %}
|
||||||
<a class="navbar-item is-disabled">No contacts found.</a>
|
<a class="navbar-item is-disabled">No contacts found.</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
<hr class="navbar-divider" style="margin: 0.2rem 0;">
|
||||||
|
<a class="navbar-item" href="{{ match_url }}">
|
||||||
|
<span class="icon is-small"><i class="fa-solid fa-link"></i></span>
|
||||||
|
<span style="margin-left: 0.35rem;">Match Contacts</span>
|
||||||
|
</a>
|
||||||
{% if is_preview %}
|
{% if is_preview %}
|
||||||
<hr class="navbar-divider" style="margin: 0.2rem 0;">
|
<hr class="navbar-divider" style="margin: 0.2rem 0;">
|
||||||
<a
|
<a
|
||||||
|
|||||||
@@ -38,7 +38,7 @@
|
|||||||
</button>
|
</button>
|
||||||
{% if show_contact_actions %}
|
{% if show_contact_actions %}
|
||||||
{% if type == 'page' %}
|
{% if type == 'page' %}
|
||||||
<a href="{% url 'signal_contacts' type=type pk=item %}"><button
|
<a href="{% url contacts_url_name type=type pk=item %}"><button
|
||||||
class="button">
|
class="button">
|
||||||
<span class="icon-text">
|
<span class="icon-text">
|
||||||
<span class="icon">
|
<span class="icon">
|
||||||
@@ -47,7 +47,7 @@
|
|||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
</a>
|
</a>
|
||||||
<a href="{% url 'signal_chats' type=type pk=item %}"><button
|
<a href="{% url chats_url_name type=type pk=item %}"><button
|
||||||
class="button">
|
class="button">
|
||||||
<span class="icon-text">
|
<span class="icon-text">
|
||||||
<span class="icon">
|
<span class="icon">
|
||||||
@@ -59,7 +59,7 @@
|
|||||||
{% else %}
|
{% else %}
|
||||||
<button
|
<button
|
||||||
hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'
|
hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'
|
||||||
hx-get="{% url 'signal_contacts' type=type pk=item %}"
|
hx-get="{% url contacts_url_name type=type pk=item %}"
|
||||||
hx-trigger="click"
|
hx-trigger="click"
|
||||||
hx-target="#{{ type }}s-here"
|
hx-target="#{{ type }}s-here"
|
||||||
hx-swap="innerHTML"
|
hx-swap="innerHTML"
|
||||||
@@ -72,7 +72,7 @@
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'
|
hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'
|
||||||
hx-get="{% url 'signal_chats' type=type pk=item %}"
|
hx-get="{% url chats_url_name type=type pk=item %}"
|
||||||
hx-trigger="click"
|
hx-trigger="click"
|
||||||
hx-target="#{{ type }}s-here"
|
hx-target="#{{ type }}s-here"
|
||||||
hx-swap="innerHTML"
|
hx-swap="innerHTML"
|
||||||
|
|||||||
@@ -68,6 +68,16 @@
|
|||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
<a href="{{ item.match_url }}"><button
|
||||||
|
class="button"
|
||||||
|
title="Match identifier to person">
|
||||||
|
<span class="icon-text">
|
||||||
|
<span class="icon">
|
||||||
|
<i class="fa-solid fa-link"></i>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</a>
|
||||||
<a href="{{ item.ai_url }}"><button
|
<a href="{{ item.ai_url }}"><button
|
||||||
class="button"
|
class="button"
|
||||||
title="Open AI workspace">
|
title="Open AI workspace">
|
||||||
@@ -102,6 +112,13 @@
|
|||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
<a href="{{ item.match_url }}"><button class="button" title="Match identifier to person">
|
||||||
|
<span class="icon-text">
|
||||||
|
<span class="icon">
|
||||||
|
<i class="fa-solid fa-link"></i>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</button></a>
|
||||||
<a href="{{ item.ai_url }}"><button class="button">
|
<a href="{{ item.ai_url }}"><button class="button">
|
||||||
<span class="icon-text">
|
<span class="icon-text">
|
||||||
<span class="icon">
|
<span class="icon">
|
||||||
|
|||||||
57
core/templates/partials/whatsapp-chats-list.html
Normal file
57
core/templates/partials/whatsapp-chats-list.html
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
{% include 'mixins/partials/notify.html' %}
|
||||||
|
<table
|
||||||
|
class="table is-fullwidth is-hoverable"
|
||||||
|
hx-target="#{{ context_object_name }}-table"
|
||||||
|
id="{{ context_object_name }}-table"
|
||||||
|
hx-swap="outerHTML"
|
||||||
|
hx-trigger="{{ context_object_name_singular }}Event from:body"
|
||||||
|
hx-get="{{ list_url }}">
|
||||||
|
<thead>
|
||||||
|
<th>chat</th>
|
||||||
|
<th>identifier</th>
|
||||||
|
<th>person</th>
|
||||||
|
<th>actions</th>
|
||||||
|
</thead>
|
||||||
|
{% for item in object_list %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ item.name|default:"WhatsApp Chat" }}</td>
|
||||||
|
<td>
|
||||||
|
<a
|
||||||
|
class="has-text-grey button nowrap-child"
|
||||||
|
onclick="window.prompt('Copy to clipboard: Ctrl+C, Enter', '{{ item.identifier }}');">
|
||||||
|
<span class="icon" data-tooltip="Copy identifier">
|
||||||
|
<i class="fa-solid fa-copy" aria-hidden="true"></i>
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
<td>{{ item.person_name|default:"-" }}</td>
|
||||||
|
<td>
|
||||||
|
<div class="buttons">
|
||||||
|
{% if type == 'page' %}
|
||||||
|
<a href="{{ item.compose_page_url }}" class="button" title="Manual text mode">
|
||||||
|
<span class="icon"><i class="fa-solid fa-paper-plane"></i></span>
|
||||||
|
</a>
|
||||||
|
{% else %}
|
||||||
|
<button
|
||||||
|
hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'
|
||||||
|
hx-get="{{ item.compose_widget_url }}"
|
||||||
|
hx-trigger="click"
|
||||||
|
hx-target="#widgets-here"
|
||||||
|
hx-swap="afterend"
|
||||||
|
class="button"
|
||||||
|
title="Manual text mode widget">
|
||||||
|
<span class="icon"><i class="fa-solid fa-paper-plane"></i></span>
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
|
<a href="{{ item.match_url }}" class="button" title="Match identifier">
|
||||||
|
<span class="icon"><i class="fa-solid fa-link"></i></span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% empty %}
|
||||||
|
<tr>
|
||||||
|
<td colspan="4" class="has-text-grey">No WhatsApp chats discovered yet.</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</table>
|
||||||
53
core/templates/partials/whatsapp-contacts-list.html
Normal file
53
core/templates/partials/whatsapp-contacts-list.html
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
{% include 'mixins/partials/notify.html' %}
|
||||||
|
<table
|
||||||
|
class="table is-fullwidth is-hoverable"
|
||||||
|
hx-target="#{{ context_object_name }}-table"
|
||||||
|
id="{{ context_object_name }}-table"
|
||||||
|
hx-swap="outerHTML"
|
||||||
|
hx-trigger="{{ context_object_name_singular }}Event from:body"
|
||||||
|
hx-get="{{ list_url }}">
|
||||||
|
<thead>
|
||||||
|
<th>name</th>
|
||||||
|
<th>identifier</th>
|
||||||
|
<th>jid</th>
|
||||||
|
<th>person</th>
|
||||||
|
<th>actions</th>
|
||||||
|
</thead>
|
||||||
|
{% for item in object_list %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ item.name|default:"-" }}</td>
|
||||||
|
<td>
|
||||||
|
<code>{{ item.identifier }}</code>
|
||||||
|
</td>
|
||||||
|
<td>{{ item.jid|default:"-" }}</td>
|
||||||
|
<td>{{ item.person_name|default:"-" }}</td>
|
||||||
|
<td>
|
||||||
|
<div class="buttons">
|
||||||
|
{% if type == 'page' %}
|
||||||
|
<a href="{{ item.compose_page_url }}" class="button" title="Open manual chat">
|
||||||
|
<span class="icon"><i class="fa-solid fa-paper-plane"></i></span>
|
||||||
|
</a>
|
||||||
|
{% else %}
|
||||||
|
<button
|
||||||
|
hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'
|
||||||
|
hx-get="{{ item.compose_widget_url }}"
|
||||||
|
hx-trigger="click"
|
||||||
|
hx-target="#widgets-here"
|
||||||
|
hx-swap="afterend"
|
||||||
|
class="button"
|
||||||
|
title="Open manual chat widget">
|
||||||
|
<span class="icon"><i class="fa-solid fa-paper-plane"></i></span>
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
|
<a href="{{ item.match_url }}" class="button" title="Match identifier">
|
||||||
|
<span class="icon"><i class="fa-solid fa-link"></i></span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% empty %}
|
||||||
|
<tr>
|
||||||
|
<td colspan="5" class="has-text-grey">No WhatsApp contacts discovered yet.</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</table>
|
||||||
@@ -29,6 +29,7 @@ from core.messaging import media_bridge
|
|||||||
from core.messaging.utils import messages_to_string
|
from core.messaging.utils import messages_to_string
|
||||||
from core.models import (
|
from core.models import (
|
||||||
AI,
|
AI,
|
||||||
|
Chat,
|
||||||
ChatSession,
|
ChatSession,
|
||||||
Message,
|
Message,
|
||||||
MessageEvent,
|
MessageEvent,
|
||||||
@@ -1334,6 +1335,120 @@ def _compose_urls(service, identifier, person_id):
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _service_icon_class(service: str) -> str:
|
||||||
|
key = str(service or "").strip().lower()
|
||||||
|
if key == "signal":
|
||||||
|
return "fa-solid fa-signal"
|
||||||
|
if key == "whatsapp":
|
||||||
|
return "fa-brands fa-whatsapp"
|
||||||
|
if key == "instagram":
|
||||||
|
return "fa-brands fa-instagram"
|
||||||
|
if key == "xmpp":
|
||||||
|
return "fa-solid fa-comments"
|
||||||
|
return "fa-solid fa-address-card"
|
||||||
|
|
||||||
|
|
||||||
|
def _manual_contact_rows(user):
|
||||||
|
rows = []
|
||||||
|
seen = set()
|
||||||
|
identifiers = (
|
||||||
|
PersonIdentifier.objects.filter(user=user)
|
||||||
|
.select_related("person")
|
||||||
|
.order_by("person__name", "service", "identifier")
|
||||||
|
)
|
||||||
|
|
||||||
|
def add_row(*, service, identifier, person=None, source="linked", account=""):
|
||||||
|
service_key = _default_service(service)
|
||||||
|
identifier_value = str(identifier or "").strip()
|
||||||
|
if not identifier_value:
|
||||||
|
return
|
||||||
|
key = (service_key, identifier_value)
|
||||||
|
if key in seen:
|
||||||
|
return
|
||||||
|
seen.add(key)
|
||||||
|
urls = _compose_urls(service_key, identifier_value, person.id if person else None)
|
||||||
|
person_name = person.name if person else ""
|
||||||
|
if not person_name:
|
||||||
|
person_name = str(account or identifier_value)
|
||||||
|
rows.append(
|
||||||
|
{
|
||||||
|
"person_name": person_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": bool(person),
|
||||||
|
"source": source,
|
||||||
|
"match_url": (
|
||||||
|
f"{reverse('compose_contact_match')}?"
|
||||||
|
f"{urlencode({'service': service_key, 'identifier': identifier_value})}"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
for row in identifiers:
|
||||||
|
add_row(
|
||||||
|
service=row.service,
|
||||||
|
identifier=row.identifier,
|
||||||
|
person=row.person,
|
||||||
|
source="linked",
|
||||||
|
)
|
||||||
|
|
||||||
|
signal_links = {
|
||||||
|
str(row.identifier): row
|
||||||
|
for row in (
|
||||||
|
PersonIdentifier.objects.filter(user=user, service="signal")
|
||||||
|
.select_related("person")
|
||||||
|
.order_by("id")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
signal_chats = Chat.objects.all().order_by("-id")[:500]
|
||||||
|
for chat in signal_chats:
|
||||||
|
for candidate in (
|
||||||
|
str(chat.source_uuid or "").strip(),
|
||||||
|
str(chat.source_number or "").strip(),
|
||||||
|
):
|
||||||
|
if not candidate:
|
||||||
|
continue
|
||||||
|
linked = signal_links.get(candidate)
|
||||||
|
add_row(
|
||||||
|
service="signal",
|
||||||
|
identifier=candidate,
|
||||||
|
person=(linked.person if linked else None),
|
||||||
|
source="signal_chat",
|
||||||
|
account=str(chat.account or ""),
|
||||||
|
)
|
||||||
|
|
||||||
|
whatsapp_links = {
|
||||||
|
str(row.identifier): row
|
||||||
|
for row in (
|
||||||
|
PersonIdentifier.objects.filter(user=user, service="whatsapp")
|
||||||
|
.select_related("person")
|
||||||
|
.order_by("id")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
wa_contacts = transport.get_runtime_state("whatsapp").get("contacts") or []
|
||||||
|
if isinstance(wa_contacts, list):
|
||||||
|
for item in wa_contacts:
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
continue
|
||||||
|
candidate = str(item.get("identifier") or item.get("jid") or "").strip()
|
||||||
|
if not candidate:
|
||||||
|
continue
|
||||||
|
linked = whatsapp_links.get(candidate)
|
||||||
|
add_row(
|
||||||
|
service="whatsapp",
|
||||||
|
identifier=candidate,
|
||||||
|
person=(linked.person if linked else None),
|
||||||
|
source="whatsapp_runtime",
|
||||||
|
account=str(item.get("name") or item.get("chat") or ""),
|
||||||
|
)
|
||||||
|
|
||||||
|
rows.sort(key=lambda row: (row["person_name"].lower(), row["service"], row["identifier"]))
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
def _load_messages(user, person_identifier, limit):
|
def _load_messages(user, person_identifier, limit):
|
||||||
if person_identifier is None:
|
if person_identifier is None:
|
||||||
return {"session": None, "messages": []}
|
return {"session": None, "messages": []}
|
||||||
@@ -1450,32 +1565,18 @@ class ComposeContactsDropdown(LoginRequiredMixin, View):
|
|||||||
def get(self, request):
|
def get(self, request):
|
||||||
all_value = str(request.GET.get("all") or "").strip().lower()
|
all_value = str(request.GET.get("all") or "").strip().lower()
|
||||||
fetch_all = all_value in {"1", "true", "yes", "y", "all"}
|
fetch_all = all_value in {"1", "true", "yes", "y", "all"}
|
||||||
preview_limit = 5
|
preview_limit = 10
|
||||||
queryset = (
|
contact_rows = _manual_contact_rows(request.user)
|
||||||
PersonIdentifier.objects.filter(user=request.user)
|
rows = contact_rows if fetch_all else contact_rows[:preview_limit]
|
||||||
.select_related("person")
|
|
||||||
.order_by("person__name", "service", "identifier")
|
|
||||||
)
|
|
||||||
rows = list(queryset) if fetch_all else list(queryset[:preview_limit])
|
|
||||||
items = []
|
|
||||||
for row in rows:
|
|
||||||
urls = _compose_urls(row.service, row.identifier, row.person_id)
|
|
||||||
items.append(
|
|
||||||
{
|
|
||||||
"person_name": row.person.name,
|
|
||||||
"service": row.service,
|
|
||||||
"identifier": row.identifier,
|
|
||||||
"compose_url": urls["page_url"],
|
|
||||||
}
|
|
||||||
)
|
|
||||||
return render(
|
return render(
|
||||||
request,
|
request,
|
||||||
"partials/nav-contacts-dropdown.html",
|
"partials/nav-contacts-dropdown.html",
|
||||||
{
|
{
|
||||||
"items": items,
|
"items": rows,
|
||||||
"manual_icon_class": "fa-solid fa-paper-plane",
|
"manual_icon_class": "fa-solid fa-paper-plane",
|
||||||
"is_preview": not fetch_all,
|
"is_preview": not fetch_all,
|
||||||
"fetch_contacts_url": f"{reverse('compose_contacts_dropdown')}?all=1",
|
"fetch_contacts_url": f"{reverse('compose_contacts_dropdown')}?all=1",
|
||||||
|
"match_url": reverse("compose_contact_match"),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1517,33 +1618,15 @@ class ComposeWorkspace(LoginRequiredMixin, View):
|
|||||||
|
|
||||||
|
|
||||||
class ComposeWorkspaceContactsWidget(LoginRequiredMixin, View):
|
class ComposeWorkspaceContactsWidget(LoginRequiredMixin, View):
|
||||||
def _contact_rows(self, user):
|
|
||||||
rows = []
|
|
||||||
queryset = (
|
|
||||||
PersonIdentifier.objects.filter(user=user)
|
|
||||||
.select_related("person")
|
|
||||||
.order_by("person__name", "service", "identifier")
|
|
||||||
)
|
|
||||||
for row in queryset:
|
|
||||||
urls = _compose_urls(row.service, row.identifier, row.person_id)
|
|
||||||
rows.append(
|
|
||||||
{
|
|
||||||
"person_name": row.person.name,
|
|
||||||
"service": row.service,
|
|
||||||
"identifier": row.identifier,
|
|
||||||
"compose_widget_url": urls["widget_url"],
|
|
||||||
}
|
|
||||||
)
|
|
||||||
return rows
|
|
||||||
|
|
||||||
def get(self, request):
|
def get(self, request):
|
||||||
limit = _safe_limit(request.GET.get("limit") or 40)
|
limit = _safe_limit(request.GET.get("limit") or 40)
|
||||||
|
contact_rows = _manual_contact_rows(request.user)
|
||||||
context = {
|
context = {
|
||||||
"title": "Manual Workspace",
|
"title": "Manual Workspace",
|
||||||
"unique": "compose-workspace-contacts",
|
"unique": "compose-workspace-contacts",
|
||||||
"window_content": "partials/compose-workspace-contacts-widget.html",
|
"window_content": "partials/compose-workspace-contacts-widget.html",
|
||||||
"widget_options": 'gs-w="4" gs-h="14" gs-x="0" gs-y="0" gs-min-w="3"',
|
"widget_options": 'gs-w="4" gs-h="14" gs-x="0" gs-y="0" gs-min-w="3"',
|
||||||
"contact_rows": self._contact_rows(request.user),
|
"contact_rows": contact_rows,
|
||||||
"limit": limit,
|
"limit": limit,
|
||||||
"limit_options": [20, 40, 60, 100, 200],
|
"limit_options": [20, 40, 60, 100, 200],
|
||||||
"manual_icon_class": "fa-solid fa-paper-plane",
|
"manual_icon_class": "fa-solid fa-paper-plane",
|
||||||
@@ -1551,6 +1634,87 @@ class ComposeWorkspaceContactsWidget(LoginRequiredMixin, View):
|
|||||||
return render(request, "mixins/wm/widget.html", context)
|
return render(request, "mixins/wm/widget.html", context)
|
||||||
|
|
||||||
|
|
||||||
|
class ComposeContactMatch(LoginRequiredMixin, View):
|
||||||
|
template_name = "pages/compose-contact-match.html"
|
||||||
|
|
||||||
|
def _service_choices(self):
|
||||||
|
return [
|
||||||
|
("signal", "Signal"),
|
||||||
|
("whatsapp", "WhatsApp"),
|
||||||
|
("instagram", "Instagram"),
|
||||||
|
("xmpp", "XMPP"),
|
||||||
|
]
|
||||||
|
|
||||||
|
def _context(self, request, notice="", level="info"):
|
||||||
|
people = (
|
||||||
|
Person.objects.filter(user=request.user)
|
||||||
|
.prefetch_related("personidentifier_set")
|
||||||
|
.order_by("name")
|
||||||
|
)
|
||||||
|
candidates = _manual_contact_rows(request.user)
|
||||||
|
return {
|
||||||
|
"people": people,
|
||||||
|
"candidates": candidates,
|
||||||
|
"service_choices": self._service_choices(),
|
||||||
|
"notice_message": notice,
|
||||||
|
"notice_level": level,
|
||||||
|
"prefill_service": _default_service(request.GET.get("service")),
|
||||||
|
"prefill_identifier": str(request.GET.get("identifier") or "").strip(),
|
||||||
|
}
|
||||||
|
|
||||||
|
def get(self, request):
|
||||||
|
return render(request, self.template_name, self._context(request))
|
||||||
|
|
||||||
|
def post(self, request):
|
||||||
|
person_id = str(request.POST.get("person_id") or "").strip()
|
||||||
|
person_name = str(request.POST.get("person_name") or "").strip()
|
||||||
|
service = _default_service(request.POST.get("service"))
|
||||||
|
identifier = str(request.POST.get("identifier") or "").strip()
|
||||||
|
if not identifier:
|
||||||
|
return render(
|
||||||
|
request,
|
||||||
|
self.template_name,
|
||||||
|
self._context(request, "Identifier is required.", "warning"),
|
||||||
|
)
|
||||||
|
person = None
|
||||||
|
if person_id:
|
||||||
|
person = Person.objects.filter(id=person_id, user=request.user).first()
|
||||||
|
if person is None and person_name:
|
||||||
|
person = Person.objects.create(user=request.user, name=person_name)
|
||||||
|
if person is None:
|
||||||
|
return render(
|
||||||
|
request,
|
||||||
|
self.template_name,
|
||||||
|
self._context(request, "Select a person or create one.", "warning"),
|
||||||
|
)
|
||||||
|
|
||||||
|
row = PersonIdentifier.objects.filter(
|
||||||
|
user=request.user,
|
||||||
|
service=service,
|
||||||
|
identifier=identifier,
|
||||||
|
).first()
|
||||||
|
if row is None:
|
||||||
|
PersonIdentifier.objects.create(
|
||||||
|
user=request.user,
|
||||||
|
person=person,
|
||||||
|
service=service,
|
||||||
|
identifier=identifier,
|
||||||
|
)
|
||||||
|
message = f"Linked {identifier} ({service}) to {person.name}."
|
||||||
|
else:
|
||||||
|
if row.person_id != person.id:
|
||||||
|
row.person = person
|
||||||
|
row.save(update_fields=["person"])
|
||||||
|
message = f"Re-linked {identifier} ({service}) to {person.name}."
|
||||||
|
else:
|
||||||
|
message = f"{identifier} ({service}) is already linked to {person.name}."
|
||||||
|
return render(
|
||||||
|
request,
|
||||||
|
self.template_name,
|
||||||
|
self._context(request, message, "success"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class ComposePage(LoginRequiredMixin, View):
|
class ComposePage(LoginRequiredMixin, View):
|
||||||
template_name = "pages/compose.html"
|
template_name = "pages/compose.html"
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import requests
|
|||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.shortcuts import render
|
from django.shortcuts import render
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
|
from urllib.parse import urlencode
|
||||||
from django.views import View
|
from django.views import View
|
||||||
from mixins.views import ObjectList, ObjectRead
|
from mixins.views import ObjectList, ObjectRead
|
||||||
|
|
||||||
@@ -67,6 +68,8 @@ class SignalAccounts(SuperUserRequiredMixin, ObjectList):
|
|||||||
"service_label": label,
|
"service_label": label,
|
||||||
"account_add_url_name": add_url_name,
|
"account_add_url_name": add_url_name,
|
||||||
"show_contact_actions": show_contact_actions,
|
"show_contact_actions": show_contact_actions,
|
||||||
|
"contacts_url_name": f"{service}_contacts",
|
||||||
|
"chats_url_name": f"{service}_chats",
|
||||||
"endpoint_base": str(
|
"endpoint_base": str(
|
||||||
getattr(settings, "SIGNAL_HTTP_URL", "http://signal:8080")
|
getattr(settings, "SIGNAL_HTTP_URL", "http://signal:8080")
|
||||||
).rstrip("/")
|
).rstrip("/")
|
||||||
@@ -187,6 +190,12 @@ class SignalChatsList(SuperUserRequiredMixin, ObjectList):
|
|||||||
),
|
),
|
||||||
"manual_icon_class": "fa-solid fa-paper-plane",
|
"manual_icon_class": "fa-solid fa-paper-plane",
|
||||||
"can_compose": bool(compose_page_url),
|
"can_compose": bool(compose_page_url),
|
||||||
|
"match_url": (
|
||||||
|
f"{reverse('compose_contact_match')}?"
|
||||||
|
f"{urlencode({'service': 'signal', 'identifier': identifier_value})}"
|
||||||
|
if identifier_value
|
||||||
|
else reverse("compose_contact_match")
|
||||||
|
),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
return rows
|
return rows
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
from django.shortcuts import render
|
from django.shortcuts import render
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
|
from urllib.parse import urlencode
|
||||||
from django.views import View
|
from django.views import View
|
||||||
from mixins.views import ObjectList, ObjectRead
|
from mixins.views import ObjectList, ObjectRead
|
||||||
|
|
||||||
from core.clients import transport
|
from core.clients import transport
|
||||||
|
from core.models import PersonIdentifier
|
||||||
|
from core.views.compose import _compose_urls, _service_icon_class
|
||||||
from core.views.manage.permissions import SuperUserRequiredMixin
|
from core.views.manage.permissions import SuperUserRequiredMixin
|
||||||
import time
|
import time
|
||||||
|
|
||||||
@@ -56,12 +59,107 @@ class WhatsAppAccounts(SuperUserRequiredMixin, ObjectList):
|
|||||||
"service": "whatsapp",
|
"service": "whatsapp",
|
||||||
"service_label": "WhatsApp",
|
"service_label": "WhatsApp",
|
||||||
"account_add_url_name": "whatsapp_account_add",
|
"account_add_url_name": "whatsapp_account_add",
|
||||||
"show_contact_actions": False,
|
"show_contact_actions": True,
|
||||||
|
"contacts_url_name": "whatsapp_contacts",
|
||||||
|
"chats_url_name": "whatsapp_chats",
|
||||||
"service_warning": transport.get_service_warning("whatsapp"),
|
"service_warning": transport.get_service_warning("whatsapp"),
|
||||||
}
|
}
|
||||||
return self._normalize_accounts(transport.list_accounts("whatsapp"))
|
return self._normalize_accounts(transport.list_accounts("whatsapp"))
|
||||||
|
|
||||||
|
|
||||||
|
class WhatsAppContactsList(SuperUserRequiredMixin, ObjectList):
|
||||||
|
list_template = "partials/whatsapp-contacts-list.html"
|
||||||
|
|
||||||
|
context_object_name_singular = "WhatsApp Contact"
|
||||||
|
context_object_name = "WhatsApp Contacts"
|
||||||
|
|
||||||
|
list_url_name = "whatsapp_contacts"
|
||||||
|
list_url_args = ["type", "pk"]
|
||||||
|
|
||||||
|
def get_queryset(self, *args, **kwargs):
|
||||||
|
state = transport.get_runtime_state("whatsapp")
|
||||||
|
runtime_contacts = state.get("contacts") or []
|
||||||
|
rows = []
|
||||||
|
seen = set()
|
||||||
|
for item in runtime_contacts:
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
continue
|
||||||
|
identifier = str(
|
||||||
|
item.get("identifier") or item.get("jid") or item.get("chat") or ""
|
||||||
|
).strip()
|
||||||
|
if not identifier or identifier in seen:
|
||||||
|
continue
|
||||||
|
seen.add(identifier)
|
||||||
|
linked = (
|
||||||
|
PersonIdentifier.objects.filter(
|
||||||
|
user=self.request.user,
|
||||||
|
service="whatsapp",
|
||||||
|
identifier=identifier,
|
||||||
|
)
|
||||||
|
.select_related("person")
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
urls = _compose_urls(
|
||||||
|
"whatsapp",
|
||||||
|
identifier,
|
||||||
|
linked.person_id if linked else None,
|
||||||
|
)
|
||||||
|
rows.append(
|
||||||
|
{
|
||||||
|
"identifier": identifier,
|
||||||
|
"jid": str(item.get("jid") or ""),
|
||||||
|
"name": str(item.get("name") or item.get("chat") or ""),
|
||||||
|
"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})}"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Include already-linked WhatsApp contacts not yet discovered by runtime.
|
||||||
|
linked_rows = (
|
||||||
|
PersonIdentifier.objects.filter(
|
||||||
|
user=self.request.user,
|
||||||
|
service="whatsapp",
|
||||||
|
)
|
||||||
|
.select_related("person")
|
||||||
|
.order_by("person__name", "identifier")
|
||||||
|
)
|
||||||
|
for row in linked_rows:
|
||||||
|
identifier = str(row.identifier or "").strip()
|
||||||
|
if not identifier or identifier in seen:
|
||||||
|
continue
|
||||||
|
seen.add(identifier)
|
||||||
|
urls = _compose_urls("whatsapp", identifier, row.person_id)
|
||||||
|
rows.append(
|
||||||
|
{
|
||||||
|
"identifier": identifier,
|
||||||
|
"jid": "",
|
||||||
|
"name": row.person.name,
|
||||||
|
"service_icon_class": _service_icon_class("whatsapp"),
|
||||||
|
"person_name": row.person.name,
|
||||||
|
"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})}"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
class WhatsAppChatsList(WhatsAppContactsList):
|
||||||
|
list_template = "partials/whatsapp-chats-list.html"
|
||||||
|
context_object_name_singular = "WhatsApp Chat"
|
||||||
|
context_object_name = "WhatsApp Chats"
|
||||||
|
list_url_name = "whatsapp_chats"
|
||||||
|
|
||||||
|
|
||||||
class WhatsAppAccountAdd(SuperUserRequiredMixin, ObjectRead):
|
class WhatsAppAccountAdd(SuperUserRequiredMixin, ObjectRead):
|
||||||
detail_template = "partials/whatsapp-account-add.html"
|
detail_template = "partials/whatsapp-account-add.html"
|
||||||
service = "whatsapp"
|
service = "whatsapp"
|
||||||
|
|||||||
Reference in New Issue
Block a user