Allow linking chats
This commit is contained in:
@@ -11,7 +11,7 @@ from signalbot import Command, Context, SignalBot
|
||||
|
||||
from core.clients import ClientBase, signalapi
|
||||
from core.messaging import ai, history, media_bridge, natural, replies, utils
|
||||
from core.models import Chat, Manipulation, PersonIdentifier, QueuedMessage
|
||||
from core.models import Chat, Manipulation, PersonIdentifier, PlatformChatLink, QueuedMessage
|
||||
from core.util import logs
|
||||
|
||||
log = logs.get_logger("signalF")
|
||||
@@ -263,6 +263,49 @@ class NewSignalBot(SignalBot):
|
||||
await self._resolve_commands()
|
||||
await self._produce_consume_messages()
|
||||
|
||||
async def _upsert_groups(self) -> None:
|
||||
groups = getattr(self, "groups", None) or []
|
||||
if not groups:
|
||||
self.log.debug("[Signal] _upsert_groups: no groups to persist")
|
||||
return
|
||||
|
||||
identifiers = await sync_to_async(list)(
|
||||
PersonIdentifier.objects.filter(service="signal").select_related("user")
|
||||
)
|
||||
seen_user_ids: set = set()
|
||||
users = []
|
||||
for pi in identifiers:
|
||||
if pi.user_id not in seen_user_ids:
|
||||
seen_user_ids.add(pi.user_id)
|
||||
users.append(pi.user)
|
||||
if not users:
|
||||
self.log.debug("[Signal] _upsert_groups: no PersonIdentifiers found — skipping")
|
||||
return
|
||||
|
||||
for user in users:
|
||||
for group in groups:
|
||||
group_id = group.get("id") or ""
|
||||
name = group.get("name") or group_id
|
||||
if not group_id:
|
||||
continue
|
||||
await sync_to_async(PlatformChatLink.objects.update_or_create)(
|
||||
user=user,
|
||||
service="signal",
|
||||
chat_identifier=group_id,
|
||||
defaults={
|
||||
"person": None,
|
||||
"person_identifier": None,
|
||||
"is_group": True,
|
||||
"chat_name": name,
|
||||
},
|
||||
)
|
||||
|
||||
self.log.info("[Signal] upserted %d groups for %d users", len(groups), len(users))
|
||||
|
||||
async def _detect_groups(self):
|
||||
await super()._detect_groups()
|
||||
await self._upsert_groups()
|
||||
|
||||
def start(self):
|
||||
"""Start bot without blocking the caller's event loop."""
|
||||
task = self._event_loop.create_task(
|
||||
|
||||
@@ -15,7 +15,7 @@ from django.core.cache import cache
|
||||
|
||||
from core.clients import ClientBase, transport
|
||||
from core.messaging import history, media_bridge
|
||||
from core.models import Message, PersonIdentifier
|
||||
from core.models import Message, PersonIdentifier, PlatformChatLink
|
||||
|
||||
|
||||
class WhatsAppClient(ClientBase):
|
||||
@@ -1514,6 +1514,7 @@ class WhatsAppClient(ClientBase):
|
||||
# Read contact-like rows directly from the session sqlite DB instead.
|
||||
contacts, source, lid_map = await self._sync_contacts_from_sqlite()
|
||||
groups, groups_source = await self._sync_groups_from_client()
|
||||
await self._upsert_groups(groups)
|
||||
now_ts = int(time.time())
|
||||
|
||||
if contacts:
|
||||
@@ -1762,6 +1763,53 @@ class WhatsAppClient(ClientBase):
|
||||
|
||||
return await asyncio.to_thread(_extract)
|
||||
|
||||
async def _upsert_groups(self, groups: list) -> None:
|
||||
if not groups:
|
||||
self.log.debug("[WA] _upsert_groups: no groups to persist")
|
||||
return
|
||||
|
||||
identifiers = await sync_to_async(list)(
|
||||
PersonIdentifier.objects.filter(service="whatsapp").select_related("user")
|
||||
)
|
||||
seen_user_ids: set = set()
|
||||
users = []
|
||||
for pi in identifiers:
|
||||
if pi.user_id not in seen_user_ids:
|
||||
seen_user_ids.add(pi.user_id)
|
||||
users.append(pi.user)
|
||||
if not users:
|
||||
self.log.debug("[WA] _upsert_groups: no PersonIdentifiers found — skipping")
|
||||
return
|
||||
|
||||
upserted = 0
|
||||
for user in users:
|
||||
for group in groups:
|
||||
identifier = group.get("identifier") or ""
|
||||
name = group.get("name") or identifier
|
||||
jid = group.get("jid") or ""
|
||||
if "@newsletter" in jid or "@newsletter" in identifier:
|
||||
continue
|
||||
await sync_to_async(PlatformChatLink.objects.update_or_create)(
|
||||
user=user,
|
||||
service="whatsapp",
|
||||
chat_identifier=identifier,
|
||||
defaults={
|
||||
"person": None,
|
||||
"person_identifier": None,
|
||||
"is_group": True,
|
||||
"chat_name": name,
|
||||
"chat_jid": jid,
|
||||
},
|
||||
)
|
||||
upserted += 1
|
||||
|
||||
self.log.info(
|
||||
"[WA] upserted %d group rows (%d groups × %d users)",
|
||||
upserted,
|
||||
len(groups),
|
||||
len(users),
|
||||
)
|
||||
|
||||
async def _sync_groups_from_client(self):
|
||||
if self._client is None:
|
||||
return [], "client_missing"
|
||||
|
||||
24
core/migrations/0026_platformchatlink_is_group.py
Normal file
24
core/migrations/0026_platformchatlink_is_group.py
Normal file
@@ -0,0 +1,24 @@
|
||||
# Generated by Django 5.2.11 on 2026-02-19 14:47
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0025_platformchatlink'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='platformchatlink',
|
||||
name='is_group',
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='platformchatlink',
|
||||
name='person',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='core.person'),
|
||||
),
|
||||
]
|
||||
@@ -173,7 +173,7 @@ class PersonIdentifier(models.Model):
|
||||
|
||||
class PlatformChatLink(models.Model):
|
||||
user = models.ForeignKey(User, on_delete=models.CASCADE)
|
||||
person = models.ForeignKey(Person, on_delete=models.CASCADE)
|
||||
person = models.ForeignKey(Person, on_delete=models.CASCADE, null=True, blank=True)
|
||||
person_identifier = models.ForeignKey(
|
||||
PersonIdentifier,
|
||||
on_delete=models.SET_NULL,
|
||||
@@ -184,6 +184,7 @@ class PlatformChatLink(models.Model):
|
||||
chat_identifier = models.CharField(max_length=255)
|
||||
chat_jid = models.CharField(max_length=255, blank=True, null=True)
|
||||
chat_name = models.CharField(max_length=255, blank=True, null=True)
|
||||
is_group = models.BooleanField(default=False)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
@@ -224,7 +225,7 @@ class PlatformChatLink(models.Model):
|
||||
return super().save(*args, **kwargs)
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.person.name} ({self.service}: {self.chat_identifier})"
|
||||
return f"{self.person.name if self.person_id else self.chat_name or self.chat_identifier} ({self.service}: {self.chat_identifier})"
|
||||
|
||||
|
||||
class ChatSession(models.Model):
|
||||
|
||||
@@ -76,7 +76,7 @@
|
||||
|
||||
{% if existing %}
|
||||
<article class="notification is-light" style="margin-top: 0.8rem;">
|
||||
Current link: <strong>{{ existing.person.name }}</strong> ← <code>{{ existing.chat_identifier }}</code>
|
||||
Current link: <strong>{{ existing.person.name|default:existing.chat_name|default:existing.chat_identifier }}</strong> ← <code>{{ existing.chat_identifier }}</code>
|
||||
</article>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
@@ -21,28 +21,47 @@
|
||||
{% if contact_rows %}
|
||||
<div class="buttons are-small" style="display: grid; gap: 0.5rem;">
|
||||
{% for row in contact_rows %}
|
||||
<button
|
||||
class="button is-fullwidth"
|
||||
style="border-radius: 8px; border: 0; background: transparent; box-shadow: none; padding: 0;"
|
||||
hx-get="{% url 'ai_workspace_person' type='widget' person_id=row.person.id %}"
|
||||
hx-include="#ai-window-form"
|
||||
hx-target="#widgets-here"
|
||||
hx-swap="afterend">
|
||||
<span class="tags has-addons" style="display: inline-flex; width: 100%; margin: 0; white-space: nowrap;">
|
||||
<span class="tag is-dark" style="min-width: 2.5rem; justify-content: center;">
|
||||
<i class="fa-solid fa-comment-dots" aria-hidden="true"></i>
|
||||
</span>
|
||||
<span class="tag is-white" style="flex: 1; display: inline-flex; align-items: center; justify-content: space-between; gap: 0.75rem; padding-left: 0.7rem; padding-right: 0.7rem; border-top: 1px solid rgba(0, 0, 0, 0.2); border-bottom: 1px solid rgba(0, 0, 0, 0.2);">
|
||||
<span style="display: inline-flex; align-items: baseline; gap: 0.35rem; min-width: 0;">
|
||||
<strong>{{ row.person.name }}</strong>
|
||||
{% if row.person %}
|
||||
<button
|
||||
class="button is-fullwidth"
|
||||
style="border-radius: 8px; border: 0; background: transparent; box-shadow: none; padding: 0;"
|
||||
hx-get="{% url 'ai_workspace_person' type='widget' person_id=row.person.id %}"
|
||||
hx-include="#ai-window-form"
|
||||
hx-target="#widgets-here"
|
||||
hx-swap="afterend">
|
||||
<span class="tags has-addons" style="display: inline-flex; width: 100%; margin: 0; white-space: nowrap;">
|
||||
<span class="tag is-dark" style="min-width: 2.5rem; justify-content: center;">
|
||||
<i class="fa-solid fa-comment-dots" aria-hidden="true"></i>
|
||||
</span>
|
||||
{% if row.last_ts_label %}
|
||||
<small style="padding-left: 0.5rem;">{{ row.last_ts_label }}</small>
|
||||
{% endif %}
|
||||
<span class="tag is-white" style="flex: 1; display: inline-flex; align-items: center; justify-content: space-between; gap: 0.75rem; padding-left: 0.7rem; padding-right: 0.7rem; border-top: 1px solid rgba(0, 0, 0, 0.2); border-bottom: 1px solid rgba(0, 0, 0, 0.2);">
|
||||
<span style="display: inline-flex; align-items: baseline; gap: 0.35rem; min-width: 0;">
|
||||
<strong>{{ row.person.name }}</strong>
|
||||
</span>
|
||||
{% if row.last_ts_label %}
|
||||
<small style="padding-left: 0.5rem;">{{ row.last_ts_label }}</small>
|
||||
{% endif %}
|
||||
</span>
|
||||
<span class="tag is-dark" style="min-width: 3.25rem; justify-content: center;">{{ row.message_count }}</span>
|
||||
</span>
|
||||
<span class="tag is-dark" style="min-width: 3.25rem; justify-content: center;">{{ row.message_count }}</span>
|
||||
</span>
|
||||
</button>
|
||||
</button>
|
||||
{% else %}
|
||||
<button
|
||||
class="button is-fullwidth"
|
||||
style="border-radius: 8px; border: 0; background: transparent; box-shadow: none; padding: 0;"
|
||||
disabled>
|
||||
<span class="tags has-addons" style="display: inline-flex; width: 100%; margin: 0; white-space: nowrap;">
|
||||
<span class="tag is-info is-light" style="min-width: 2.5rem; justify-content: center;">
|
||||
<i class="fa-solid fa-users" aria-hidden="true"></i>
|
||||
</span>
|
||||
<span class="tag is-white" style="flex: 1; display: inline-flex; align-items: center; justify-content: space-between; gap: 0.75rem; padding-left: 0.7rem; padding-right: 0.7rem; border-top: 1px solid rgba(0, 0, 0, 0.2); border-bottom: 1px solid rgba(0, 0, 0, 0.2);">
|
||||
<span style="display: inline-flex; align-items: baseline; gap: 0.35rem; min-width: 0;">
|
||||
<strong>{{ row.chat_name }}</strong>
|
||||
<small class="has-text-grey">{{ row.service }}</small>
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
|
||||
@@ -37,6 +37,8 @@
|
||||
<p class="is-size-6" style="margin-bottom: 0;">
|
||||
{% if person %}
|
||||
{{ person.name }}
|
||||
{% elif group_name %}
|
||||
{{ group_name }}
|
||||
{% else %}
|
||||
{{ identifier }}
|
||||
{% endif %}
|
||||
|
||||
@@ -18,35 +18,44 @@
|
||||
</thead>
|
||||
{% for item in object_list %}
|
||||
<tr>
|
||||
<td>{{ item.chat.source_number }}</td>
|
||||
<td>{% if item.chat %}{{ item.chat.source_number }}{% endif %}</td>
|
||||
<td>
|
||||
<a
|
||||
class="has-text-grey button nowrap-child"
|
||||
onclick="window.prompt('Copy to clipboard: Ctrl+C, Enter', '{{ item.chat.source_uuid }}');">
|
||||
<span class="icon" data-tooltip="Copy to clipboard">
|
||||
<i class="fa-solid fa-copy" aria-hidden="true"></i>
|
||||
</span>
|
||||
</a>
|
||||
{% if item.chat %}
|
||||
<a
|
||||
class="has-text-grey button nowrap-child"
|
||||
onclick="window.prompt('Copy to clipboard: Ctrl+C, Enter', '{{ item.chat.source_uuid }}');">
|
||||
<span class="icon" data-tooltip="Copy to clipboard">
|
||||
<i class="fa-solid fa-copy" aria-hidden="true"></i>
|
||||
</span>
|
||||
</a>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{% if item.chat %}{{ item.chat.account }}{% endif %}</td>
|
||||
<td>
|
||||
{% if item.is_group %}
|
||||
<span class="tag is-info is-light is-small mr-1"><i class="fa-solid fa-users"></i></span>
|
||||
{% endif %}
|
||||
{% if item.chat %}{{ item.chat.source_name }}{% else %}{{ item.name }}{% endif %}
|
||||
</td>
|
||||
<td>{{ item.chat.account }}</td>
|
||||
<td>{{ item.chat.source_name }}</td>
|
||||
<td>{{ item.person_name|default:"-" }}</td>
|
||||
<td>
|
||||
<div class="buttons">
|
||||
<button
|
||||
hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'
|
||||
hx-delete="{# url 'account_delete' type=type pk=item.id #}"
|
||||
hx-trigger="click"
|
||||
hx-target="#modals-here"
|
||||
hx-swap="innerHTML"
|
||||
hx-confirm="Are you sure you wish to unlink {{ item.chat }}?"
|
||||
class="button">
|
||||
<span class="icon-text">
|
||||
<span class="icon">
|
||||
<i class="fa-solid fa-xmark"></i>
|
||||
{% if not item.is_group %}
|
||||
<button
|
||||
hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'
|
||||
hx-delete="{# url 'account_delete' type=type pk=item.id #}"
|
||||
hx-trigger="click"
|
||||
hx-target="#modals-here"
|
||||
hx-swap="innerHTML"
|
||||
hx-confirm="Are you sure you wish to unlink {{ item.chat }}?"
|
||||
class="button">
|
||||
<span class="icon-text">
|
||||
<span class="icon">
|
||||
<i class="fa-solid fa-xmark"></i>
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
</button>
|
||||
{% endif %}
|
||||
{% if type == 'page' %}
|
||||
{% if item.can_compose %}
|
||||
<a href="{{ item.compose_page_url }}"><button
|
||||
@@ -68,16 +77,18 @@
|
||||
</span>
|
||||
</button>
|
||||
{% 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>
|
||||
{% if not item.is_group %}
|
||||
<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>
|
||||
</span>
|
||||
</button>
|
||||
</a>
|
||||
</button>
|
||||
</a>
|
||||
{% endif %}
|
||||
<a href="{{ item.ai_url }}"><button
|
||||
class="button"
|
||||
title="Open AI workspace">
|
||||
@@ -112,13 +123,15 @@
|
||||
</span>
|
||||
</button>
|
||||
{% 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>
|
||||
{% if not item.is_group %}
|
||||
<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>
|
||||
</span>
|
||||
</button></a>
|
||||
</button></a>
|
||||
{% endif %}
|
||||
<a href="{{ item.ai_url }}"><button class="button">
|
||||
<span class="icon-text">
|
||||
<span class="icon">
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
</thead>
|
||||
{% for item in object_list %}
|
||||
<tr>
|
||||
<td>{{ item.name|default:"WhatsApp Chat" }}</td>
|
||||
<td>{% if item.is_group %}<span class="tag is-info is-light is-small mr-1"><i class="fa-solid fa-users"></i></span>{% endif %}{{ item.name|default:"WhatsApp Chat" }}</td>
|
||||
<td>
|
||||
<a
|
||||
class="has-text-grey button nowrap-child"
|
||||
|
||||
@@ -38,6 +38,7 @@ from core.models import (
|
||||
PatternMitigationPlan,
|
||||
Person,
|
||||
PersonIdentifier,
|
||||
PlatformChatLink,
|
||||
WorkspaceConversation,
|
||||
)
|
||||
from core.realtime.typing_state import get_person_typing_state
|
||||
@@ -1469,11 +1470,31 @@ def _context_base(user, service, identifier, person):
|
||||
identifier = person_identifier.identifier
|
||||
person = person_identifier.person
|
||||
|
||||
if person_identifier is None and identifier:
|
||||
bare_id = identifier.split("@", 1)[0].strip()
|
||||
group_link = PlatformChatLink.objects.filter(
|
||||
user=user,
|
||||
service=service,
|
||||
chat_identifier=bare_id,
|
||||
is_group=True,
|
||||
).first()
|
||||
if group_link:
|
||||
return {
|
||||
"person_identifier": None,
|
||||
"service": service,
|
||||
"identifier": f"{bare_id}@g.us",
|
||||
"person": None,
|
||||
"is_group": True,
|
||||
"group_name": group_link.chat_name or bare_id,
|
||||
}
|
||||
|
||||
return {
|
||||
"person_identifier": person_identifier,
|
||||
"service": service,
|
||||
"identifier": identifier,
|
||||
"person": person,
|
||||
"is_group": False,
|
||||
"group_name": "",
|
||||
}
|
||||
|
||||
|
||||
@@ -2095,6 +2116,8 @@ def _panel_context(
|
||||
"typing_state_json": json.dumps(typing_state),
|
||||
"platform_options": platform_options,
|
||||
"recent_contacts": recent_contacts,
|
||||
"is_group": base.get("is_group", False),
|
||||
"group_name": base.get("group_name", ""),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ from django.views import View
|
||||
from mixins.views import ObjectList, ObjectRead
|
||||
|
||||
from core.clients import transport
|
||||
from core.models import Chat, PersonIdentifier
|
||||
from core.models import Chat, PersonIdentifier, PlatformChatLink
|
||||
from core.views.manage.permissions import SuperUserRequiredMixin
|
||||
|
||||
|
||||
@@ -199,6 +199,31 @@ class SignalChatsList(SuperUserRequiredMixin, ObjectList):
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
for link in PlatformChatLink.objects.filter(
|
||||
user=self.request.user,
|
||||
service="signal",
|
||||
is_group=True,
|
||||
):
|
||||
group_id = str(link.chat_identifier or "").strip()
|
||||
if not group_id:
|
||||
continue
|
||||
rows.append(
|
||||
{
|
||||
"chat": None,
|
||||
"compose_page_url": "",
|
||||
"compose_widget_url": "",
|
||||
"ai_url": reverse("ai_workspace"),
|
||||
"person_name": "",
|
||||
"manual_icon_class": "fa-solid fa-users",
|
||||
"can_compose": False,
|
||||
"match_url": "",
|
||||
"is_group": True,
|
||||
"name": link.chat_name or group_id,
|
||||
"identifier": group_id,
|
||||
}
|
||||
)
|
||||
|
||||
return rows
|
||||
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ from django.views import View
|
||||
from mixins.views import ObjectList, ObjectRead
|
||||
|
||||
from core.clients import transport
|
||||
from core.models import PersonIdentifier
|
||||
from core.models import PersonIdentifier, PlatformChatLink
|
||||
from core.util import logs
|
||||
from core.views.compose import _compose_urls, _service_icon_class
|
||||
from core.views.manage.permissions import SuperUserRequiredMixin
|
||||
@@ -288,7 +288,10 @@ class WhatsAppChatsList(WhatsAppContactsList):
|
||||
identifier = str(key or "").strip()
|
||||
if not identifier:
|
||||
continue
|
||||
if "@newsletter" in identifier:
|
||||
continue
|
||||
identifier = identifier.split("@", 1)[0].strip() or identifier
|
||||
identifier = identifier.lstrip("+")
|
||||
if identifier in seen:
|
||||
continue
|
||||
seen.add(identifier)
|
||||
@@ -327,50 +330,88 @@ class WhatsAppChatsList(WhatsAppContactsList):
|
||||
|
||||
if rows:
|
||||
rows.sort(key=lambda row: row.get("last_ts", 0), reverse=True)
|
||||
return rows
|
||||
else:
|
||||
# Fallback: if no anchors yet, surface the runtime contacts (best effort live state)
|
||||
for item in combined_contacts:
|
||||
raw_item_id = str(
|
||||
item.get("identifier") or item.get("jid") or item.get("chat") or ""
|
||||
).strip()
|
||||
if "@newsletter" in raw_item_id:
|
||||
continue
|
||||
identifier = raw_item_id
|
||||
if not identifier:
|
||||
continue
|
||||
identifier = identifier.split("@", 1)[0].strip()
|
||||
if not identifier or identifier in seen:
|
||||
continue
|
||||
seen.add(identifier)
|
||||
jid = str(item.get("jid") or "").strip()
|
||||
linked = self._linked_identifier(identifier, jid)
|
||||
urls = _compose_urls(
|
||||
"whatsapp",
|
||||
identifier,
|
||||
linked.person_id if linked else None,
|
||||
)
|
||||
name = (
|
||||
str(item.get("name") or item.get("chat") or "").strip()
|
||||
or (linked.person.name if linked else "")
|
||||
or jid
|
||||
or identifier
|
||||
or "WhatsApp Chat"
|
||||
)
|
||||
rows.append(
|
||||
{
|
||||
"identifier": identifier,
|
||||
"jid": jid or identifier,
|
||||
"name": name,
|
||||
"is_group": False,
|
||||
"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,
|
||||
}
|
||||
)
|
||||
|
||||
# Fallback: if no anchors yet, surface the runtime contacts (best effort live state)
|
||||
for item in combined_contacts:
|
||||
identifier = str(
|
||||
item.get("identifier") or item.get("jid") or item.get("chat") or ""
|
||||
).strip()
|
||||
if not identifier:
|
||||
db_group_ids = set()
|
||||
db_groups = PlatformChatLink.objects.filter(
|
||||
user=self.request.user,
|
||||
service="whatsapp",
|
||||
is_group=True,
|
||||
)
|
||||
for link in db_groups:
|
||||
bare_id = str(link.chat_identifier or "").strip()
|
||||
if not bare_id:
|
||||
continue
|
||||
identifier = identifier.split("@", 1)[0].strip()
|
||||
if not identifier or identifier in seen:
|
||||
continue
|
||||
seen.add(identifier)
|
||||
jid = str(item.get("jid") or "").strip()
|
||||
linked = self._linked_identifier(identifier, jid)
|
||||
urls = _compose_urls(
|
||||
"whatsapp",
|
||||
identifier,
|
||||
linked.person_id if linked else None,
|
||||
)
|
||||
name = (
|
||||
str(item.get("name") or item.get("chat") or "").strip()
|
||||
or (linked.person.name if linked else "")
|
||||
or jid
|
||||
or identifier
|
||||
or "WhatsApp Chat"
|
||||
)
|
||||
rows.append(
|
||||
{
|
||||
"identifier": identifier,
|
||||
"jid": jid or identifier,
|
||||
"name": name,
|
||||
"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,
|
||||
}
|
||||
)
|
||||
return rows
|
||||
db_group_ids.add(bare_id)
|
||||
if bare_id not in seen:
|
||||
seen.add(bare_id)
|
||||
jid = link.chat_jid or f"{bare_id}@g.us"
|
||||
urls = _compose_urls("whatsapp", bare_id, None)
|
||||
rows.append(
|
||||
{
|
||||
"identifier": bare_id,
|
||||
"jid": jid,
|
||||
"name": link.chat_name or bare_id,
|
||||
"is_group": True,
|
||||
"service_icon_class": _service_icon_class("whatsapp"),
|
||||
"person_name": "",
|
||||
"compose_page_url": urls["page_url"],
|
||||
"compose_widget_url": urls["widget_url"],
|
||||
"match_url": "",
|
||||
"last_ts": int(link.updated_at.timestamp()),
|
||||
}
|
||||
)
|
||||
|
||||
for row in rows:
|
||||
if not row.get("is_group") and row.get("identifier") in db_group_ids:
|
||||
row["is_group"] = True
|
||||
|
||||
return [row for row in rows if row.get("is_group")]
|
||||
|
||||
|
||||
class WhatsAppAccountAdd(SuperUserRequiredMixin, ObjectRead):
|
||||
|
||||
@@ -34,6 +34,7 @@ from core.models import (
|
||||
PatternMitigationRule,
|
||||
Person,
|
||||
PersonIdentifier,
|
||||
PlatformChatLink,
|
||||
QueuedMessage,
|
||||
WorkspaceConversation,
|
||||
WorkspaceMetricSnapshot,
|
||||
@@ -3538,6 +3539,24 @@ class AIWorkspaceContactsWidget(LoginRequiredMixin, View):
|
||||
}
|
||||
)
|
||||
rows.sort(key=lambda row: row["last_ts"] or 0, reverse=True)
|
||||
|
||||
for link in PlatformChatLink.objects.filter(user=user, is_group=True).order_by(
|
||||
"service", "chat_name"
|
||||
):
|
||||
rows.append(
|
||||
{
|
||||
"person": None,
|
||||
"is_group": True,
|
||||
"chat_name": link.chat_name or link.chat_identifier,
|
||||
"service": link.service,
|
||||
"chat_identifier": link.chat_identifier,
|
||||
"message_count": 0,
|
||||
"last_text": "",
|
||||
"last_ts": None,
|
||||
"last_ts_label": "",
|
||||
}
|
||||
)
|
||||
|
||||
return rows
|
||||
|
||||
def get(self, request, type):
|
||||
|
||||
@@ -1,353 +0,0 @@
|
||||
version: "2.2"
|
||||
|
||||
services:
|
||||
app:
|
||||
image: xf/gia:prod
|
||||
container_name: gia
|
||||
build:
|
||||
context: .
|
||||
args:
|
||||
OPERATION: ${OPERATION}
|
||||
volumes:
|
||||
- ${REPO_DIR}:/code
|
||||
- ${REPO_DIR}/docker/uwsgi.ini:/conf/uwsgi.ini
|
||||
- ${APP_DATABASE_FILE}:/conf/db.sqlite3
|
||||
- gia_whatsapp_data:${WHATSAPP_DB_DIR}
|
||||
- type: bind
|
||||
source: /code/vrun
|
||||
target: /var/run
|
||||
environment:
|
||||
APP_PORT: "${APP_PORT}"
|
||||
REPO_DIR: "${REPO_DIR}"
|
||||
APP_LOCAL_SETTINGS: "${APP_LOCAL_SETTINGS}"
|
||||
APP_DATABASE_FILE: "${APP_DATABASE_FILE}"
|
||||
DOMAIN: "${DOMAIN}"
|
||||
URL: "${URL}"
|
||||
ALLOWED_HOSTS: "${ALLOWED_HOSTS}"
|
||||
NOTIFY_TOPIC: "${NOTIFY_TOPIC}"
|
||||
CSRF_TRUSTED_ORIGINS: "${CSRF_TRUSTED_ORIGINS}"
|
||||
DEBUG: "${DEBUG}"
|
||||
SECRET_KEY: "${SECRET_KEY}"
|
||||
STATIC_ROOT: "${STATIC_ROOT}"
|
||||
REGISTRATION_OPEN: "${REGISTRATION_OPEN}"
|
||||
OPERATION: "${OPERATION}"
|
||||
SIGNAL_NUMBER: "${SIGNAL_NUMBER}"
|
||||
SIGNAL_HTTP_URL: "${SIGNAL_HTTP_URL:-http://signal:8080}"
|
||||
WHATSAPP_DB_DIR: "${WHATSAPP_DB_DIR}"
|
||||
WHATSAPP_ENABLED: "${WHATSAPP_ENABLED}"
|
||||
INSTAGRAM_ENABLED: "${INSTAGRAM_ENABLED}"
|
||||
COMPOSE_WS_ENABLED: "${COMPOSE_WS_ENABLED}"
|
||||
XMPP_ADDRESS: "${XMPP_ADDRESS}"
|
||||
XMPP_JID: "${XMPP_JID}"
|
||||
XMPP_PORT: "${XMPP_PORT}"
|
||||
XMPP_SECRET: "${XMPP_SECRET}"
|
||||
depends_on:
|
||||
redis:
|
||||
condition: service_healthy
|
||||
migration:
|
||||
condition: service_started
|
||||
collectstatic:
|
||||
condition: service_started
|
||||
# deploy:
|
||||
# resources:
|
||||
# limits:
|
||||
# cpus: '0.1'
|
||||
# memory: 0.25G
|
||||
#network_mode: host
|
||||
|
||||
asgi:
|
||||
image: xf/gia:prod
|
||||
container_name: asgi_gia
|
||||
build:
|
||||
context: .
|
||||
args:
|
||||
OPERATION: ${OPERATION}
|
||||
command: sh -c 'rm -f /var/run/asgi-gia.sock && . /venv/bin/activate && python -m pip install --disable-pip-version-check -q uvicorn && python -m uvicorn app.asgi:application --uds /var/run/asgi-gia.sock --workers 1'
|
||||
volumes:
|
||||
- ${REPO_DIR}:/code
|
||||
- ${APP_DATABASE_FILE}:/conf/db.sqlite3
|
||||
- gia_whatsapp_data:${WHATSAPP_DB_DIR}
|
||||
- type: bind
|
||||
source: /code/vrun
|
||||
target: /var/run
|
||||
environment:
|
||||
APP_PORT: "${APP_PORT}"
|
||||
REPO_DIR: "${REPO_DIR}"
|
||||
APP_LOCAL_SETTINGS: "${APP_LOCAL_SETTINGS}"
|
||||
APP_DATABASE_FILE: "${APP_DATABASE_FILE}"
|
||||
DOMAIN: "${DOMAIN}"
|
||||
URL: "${URL}"
|
||||
ALLOWED_HOSTS: "${ALLOWED_HOSTS}"
|
||||
NOTIFY_TOPIC: "${NOTIFY_TOPIC}"
|
||||
CSRF_TRUSTED_ORIGINS: "${CSRF_TRUSTED_ORIGINS}"
|
||||
DEBUG: "${DEBUG}"
|
||||
SECRET_KEY: "${SECRET_KEY}"
|
||||
STATIC_ROOT: "${STATIC_ROOT}"
|
||||
REGISTRATION_OPEN: "${REGISTRATION_OPEN}"
|
||||
OPERATION: "${OPERATION}"
|
||||
SIGNAL_NUMBER: "${SIGNAL_NUMBER}"
|
||||
SIGNAL_HTTP_URL: "${SIGNAL_HTTP_URL:-http://signal:8080}"
|
||||
WHATSAPP_DB_DIR: "${WHATSAPP_DB_DIR}"
|
||||
WHATSAPP_ENABLED: "${WHATSAPP_ENABLED}"
|
||||
INSTAGRAM_ENABLED: "${INSTAGRAM_ENABLED}"
|
||||
COMPOSE_WS_ENABLED: "${COMPOSE_WS_ENABLED}"
|
||||
XMPP_ADDRESS: "${XMPP_ADDRESS}"
|
||||
XMPP_JID: "${XMPP_JID}"
|
||||
XMPP_PORT: "${XMPP_PORT}"
|
||||
XMPP_SECRET: "${XMPP_SECRET}"
|
||||
depends_on:
|
||||
redis:
|
||||
condition: service_healthy
|
||||
migration:
|
||||
condition: service_started
|
||||
collectstatic:
|
||||
condition: service_started
|
||||
|
||||
# giadb:
|
||||
# image: manticoresearch/manticore:dev
|
||||
# container_name: giadb
|
||||
# restart: always
|
||||
# environment:
|
||||
# - EXTRA=1
|
||||
# volumes:
|
||||
# - ./docker/data:/var/lib/manticore
|
||||
# #- ./docker/manticore.conf:/etc/manticoresearch/manticore.conf
|
||||
# network_mode: host
|
||||
|
||||
signal-cli-rest-api:
|
||||
image: bbernhard/signal-cli-rest-api:latest
|
||||
container_name: signal
|
||||
environment:
|
||||
- MODE=json-rpc #supported modes: json-rpc, native, normal
|
||||
# - AUTO_RECEIVE_SCHEDULE=0 22 * * *
|
||||
# ports:
|
||||
# - "8080:8080"
|
||||
volumes:
|
||||
- "./signal-cli-config:/home/.local/share/signal-cli"
|
||||
#network_mode: host
|
||||
|
||||
ur:
|
||||
image: xf/gia:prod
|
||||
container_name: ur_gia
|
||||
build:
|
||||
context: .
|
||||
args:
|
||||
OPERATION: ${OPERATION}
|
||||
command: sh -c '. /venv/bin/activate && python manage.py ur'
|
||||
volumes:
|
||||
- ${REPO_DIR}:/code
|
||||
- ${REPO_DIR}/docker/uwsgi.ini:/conf/uwsgi.ini
|
||||
- ${APP_DATABASE_FILE}:/conf/db.sqlite3
|
||||
- gia_whatsapp_data:${WHATSAPP_DB_DIR}
|
||||
- type: bind
|
||||
source: /code/vrun
|
||||
target: /var/run
|
||||
environment:
|
||||
APP_PORT: "${APP_PORT}"
|
||||
REPO_DIR: "${REPO_DIR}"
|
||||
APP_LOCAL_SETTINGS: "${APP_LOCAL_SETTINGS}"
|
||||
APP_DATABASE_FILE: "${APP_DATABASE_FILE}"
|
||||
DOMAIN: "${DOMAIN}"
|
||||
URL: "${URL}"
|
||||
ALLOWED_HOSTS: "${ALLOWED_HOSTS}"
|
||||
NOTIFY_TOPIC: "${NOTIFY_TOPIC}"
|
||||
CSRF_TRUSTED_ORIGINS: "${CSRF_TRUSTED_ORIGINS}"
|
||||
DEBUG: "${DEBUG}"
|
||||
SECRET_KEY: "${SECRET_KEY}"
|
||||
STATIC_ROOT: "${STATIC_ROOT}"
|
||||
REGISTRATION_OPEN: "${REGISTRATION_OPEN}"
|
||||
OPERATION: "${OPERATION}"
|
||||
SIGNAL_NUMBER: "${SIGNAL_NUMBER}"
|
||||
SIGNAL_HTTP_URL: "${SIGNAL_HTTP_URL:-http://signal:8080}"
|
||||
WHATSAPP_DB_DIR: "${WHATSAPP_DB_DIR}"
|
||||
WHATSAPP_ENABLED: "${WHATSAPP_ENABLED}"
|
||||
INSTAGRAM_ENABLED: "${INSTAGRAM_ENABLED}"
|
||||
XMPP_ADDRESS: "${XMPP_ADDRESS}"
|
||||
XMPP_JID: "${XMPP_JID}"
|
||||
XMPP_PORT: "${XMPP_PORT}"
|
||||
XMPP_SECRET: "${XMPP_SECRET}"
|
||||
depends_on:
|
||||
redis:
|
||||
condition: service_healthy
|
||||
migration:
|
||||
condition: service_started
|
||||
collectstatic:
|
||||
condition: service_started
|
||||
# deploy:
|
||||
# resources:
|
||||
# limits:
|
||||
# cpus: '0.25'
|
||||
# memory: 0.25G
|
||||
#network_mode: host
|
||||
|
||||
scheduling:
|
||||
image: xf/gia:prod
|
||||
container_name: scheduling_gia
|
||||
build:
|
||||
context: .
|
||||
args:
|
||||
OPERATION: ${OPERATION}
|
||||
command: sh -c '. /venv/bin/activate && python manage.py scheduling'
|
||||
volumes:
|
||||
- ${REPO_DIR}:/code
|
||||
- ${REPO_DIR}/docker/uwsgi.ini:/conf/uwsgi.ini
|
||||
- ${APP_DATABASE_FILE}:/conf/db.sqlite3
|
||||
- type: bind
|
||||
source: /code/vrun
|
||||
target: /var/run
|
||||
environment:
|
||||
APP_PORT: "${APP_PORT}"
|
||||
REPO_DIR: "${REPO_DIR}"
|
||||
APP_LOCAL_SETTINGS: "${APP_LOCAL_SETTINGS}"
|
||||
APP_DATABASE_FILE: "${APP_DATABASE_FILE}"
|
||||
DOMAIN: "${DOMAIN}"
|
||||
URL: "${URL}"
|
||||
ALLOWED_HOSTS: "${ALLOWED_HOSTS}"
|
||||
NOTIFY_TOPIC: "${NOTIFY_TOPIC}"
|
||||
CSRF_TRUSTED_ORIGINS: "${CSRF_TRUSTED_ORIGINS}"
|
||||
DEBUG: "${DEBUG}"
|
||||
SECRET_KEY: "${SECRET_KEY}"
|
||||
STATIC_ROOT: "${STATIC_ROOT}"
|
||||
REGISTRATION_OPEN: "${REGISTRATION_OPEN}"
|
||||
OPERATION: "${OPERATION}"
|
||||
SIGNAL_NUMBER: "${SIGNAL_NUMBER}"
|
||||
SIGNAL_HTTP_URL: "${SIGNAL_HTTP_URL:-http://signal:8080}"
|
||||
WHATSAPP_ENABLED: "${WHATSAPP_ENABLED}"
|
||||
INSTAGRAM_ENABLED: "${INSTAGRAM_ENABLED}"
|
||||
XMPP_ADDRESS: "${XMPP_ADDRESS}"
|
||||
XMPP_JID: "${XMPP_JID}"
|
||||
XMPP_PORT: "${XMPP_PORT}"
|
||||
XMPP_SECRET: "${XMPP_SECRET}"
|
||||
depends_on:
|
||||
redis:
|
||||
condition: service_healthy
|
||||
migration:
|
||||
condition: service_started
|
||||
collectstatic:
|
||||
condition: service_started
|
||||
# deploy:
|
||||
# resources:
|
||||
# limits:
|
||||
# cpus: '0.25'
|
||||
# memory: 0.25G
|
||||
#network_mode: host
|
||||
|
||||
migration:
|
||||
image: xf/gia:prod
|
||||
container_name: migration_gia
|
||||
build:
|
||||
context: .
|
||||
args:
|
||||
OPERATION: ${OPERATION}
|
||||
command: sh -c '. /venv/bin/activate && python manage.py migrate --noinput'
|
||||
volumes:
|
||||
- ${REPO_DIR}:/code
|
||||
- ${APP_DATABASE_FILE}:/conf/db.sqlite3
|
||||
- type: bind
|
||||
source: /code/vrun
|
||||
target: /var/run
|
||||
environment:
|
||||
APP_PORT: "${APP_PORT}"
|
||||
REPO_DIR: "${REPO_DIR}"
|
||||
APP_LOCAL_SETTINGS: "${APP_LOCAL_SETTINGS}"
|
||||
APP_DATABASE_FILE: "${APP_DATABASE_FILE}"
|
||||
DOMAIN: "${DOMAIN}"
|
||||
URL: "${URL}"
|
||||
ALLOWED_HOSTS: "${ALLOWED_HOSTS}"
|
||||
NOTIFY_TOPIC: "${NOTIFY_TOPIC}"
|
||||
CSRF_TRUSTED_ORIGINS: "${CSRF_TRUSTED_ORIGINS}"
|
||||
DEBUG: "${DEBUG}"
|
||||
SECRET_KEY: "${SECRET_KEY}"
|
||||
STATIC_ROOT: "${STATIC_ROOT}"
|
||||
REGISTRATION_OPEN: "${REGISTRATION_OPEN}"
|
||||
OPERATION: "${OPERATION}"
|
||||
SIGNAL_NUMBER: "${SIGNAL_NUMBER}"
|
||||
SIGNAL_HTTP_URL: "${SIGNAL_HTTP_URL:-http://signal:8080}"
|
||||
WHATSAPP_ENABLED: "${WHATSAPP_ENABLED}"
|
||||
INSTAGRAM_ENABLED: "${INSTAGRAM_ENABLED}"
|
||||
XMPP_ADDRESS: "${XMPP_ADDRESS}"
|
||||
XMPP_JID: "${XMPP_JID}"
|
||||
XMPP_PORT: "${XMPP_PORT}"
|
||||
XMPP_SECRET: "${XMPP_SECRET}"
|
||||
# deploy:
|
||||
# resources:
|
||||
# limits:
|
||||
# cpus: '0.25'
|
||||
# memory: 0.25G
|
||||
#network_mode: host
|
||||
|
||||
collectstatic:
|
||||
image: xf/gia:prod
|
||||
container_name: collectstatic_gia
|
||||
build:
|
||||
context: .
|
||||
args:
|
||||
OPERATION: ${OPERATION}
|
||||
command: sh -c '. /venv/bin/activate && python manage.py collectstatic --noinput'
|
||||
volumes:
|
||||
- ${REPO_DIR}:/code
|
||||
- ${APP_DATABASE_FILE}:/conf/db.sqlite3
|
||||
- type: bind
|
||||
source: /code/vrun
|
||||
target: /var/run
|
||||
environment:
|
||||
APP_PORT: "${APP_PORT}"
|
||||
REPO_DIR: "${REPO_DIR}"
|
||||
APP_LOCAL_SETTINGS: "${APP_LOCAL_SETTINGS}"
|
||||
APP_DATABASE_FILE: "${APP_DATABASE_FILE}"
|
||||
DOMAIN: "${DOMAIN}"
|
||||
URL: "${URL}"
|
||||
ALLOWED_HOSTS: "${ALLOWED_HOSTS}"
|
||||
NOTIFY_TOPIC: "${NOTIFY_TOPIC}"
|
||||
CSRF_TRUSTED_ORIGINS: "${CSRF_TRUSTED_ORIGINS}"
|
||||
DEBUG: "${DEBUG}"
|
||||
SECRET_KEY: "${SECRET_KEY}"
|
||||
STATIC_ROOT: "${STATIC_ROOT}"
|
||||
REGISTRATION_OPEN: "${REGISTRATION_OPEN}"
|
||||
OPERATION: "${OPERATION}"
|
||||
SIGNAL_NUMBER: "${SIGNAL_NUMBER}"
|
||||
SIGNAL_HTTP_URL: "${SIGNAL_HTTP_URL:-http://signal:8080}"
|
||||
WHATSAPP_ENABLED: "${WHATSAPP_ENABLED}"
|
||||
INSTAGRAM_ENABLED: "${INSTAGRAM_ENABLED}"
|
||||
XMPP_ADDRESS: "${XMPP_ADDRESS}"
|
||||
XMPP_JID: "${XMPP_JID}"
|
||||
XMPP_PORT: "${XMPP_PORT}"
|
||||
XMPP_SECRET: "${XMPP_SECRET}"
|
||||
# deploy:
|
||||
# resources:
|
||||
# limits:
|
||||
# cpus: '0.25'
|
||||
# memory: 0.25G
|
||||
#network_mode: host
|
||||
|
||||
# Watchers disabled - use manual restart for ur and scheduling services
|
||||
# uWSGI auto-reload is enabled in uwsgi.ini for core code changes
|
||||
# To restart ur after code changes: docker-compose restart ur
|
||||
# To restart scheduling after code changes: docker-compose restart scheduling
|
||||
|
||||
redis:
|
||||
image: redis
|
||||
container_name: redis_gia
|
||||
command: redis-server /etc/redis.conf
|
||||
volumes:
|
||||
- ${REPO_DIR}/docker/redis.conf:/etc/redis.conf
|
||||
- gia_redis_data:/data
|
||||
- type: bind
|
||||
source: /code/vrun
|
||||
target: /var/run
|
||||
healthcheck:
|
||||
test:
|
||||
["CMD", "redis-cli", "-s", "/var/run/gia-redis.sock", "ping"]
|
||||
interval: 2s
|
||||
timeout: 2s
|
||||
retries: 15
|
||||
# deploy:
|
||||
# resources:
|
||||
# limits:
|
||||
# cpus: '0.25'
|
||||
# memory: 0.25G
|
||||
#network_mode: host
|
||||
|
||||
volumes:
|
||||
gia_redis_data: {}
|
||||
gia_whatsapp_data: {}
|
||||
@@ -33,6 +33,9 @@ require_podman() {
|
||||
|
||||
ensure_dirs() {
|
||||
mkdir -p "$REDIS_DATA_DIR" "$WHATSAPP_DATA_DIR" "$VRUN_DIR" "$ROOT_DIR/signal-cli-config"
|
||||
# Container runs as uid 1000 (xf); rootless Podman remaps uids so plain
|
||||
# chown won't work — podman unshare translates to the correct host uid.
|
||||
podman unshare chown 1000:1000 "$WHATSAPP_DATA_DIR" 2>/dev/null || true
|
||||
}
|
||||
|
||||
rm_if_exists() {
|
||||
|
||||
Reference in New Issue
Block a user