Allow linking chats

This commit is contained in:
2026-02-19 17:13:34 +00:00
parent bac2841298
commit 0816687c71
15 changed files with 369 additions and 461 deletions

View File

@@ -11,7 +11,7 @@ from signalbot import Command, Context, SignalBot
from core.clients import ClientBase, signalapi from core.clients import ClientBase, signalapi
from core.messaging import ai, history, media_bridge, natural, replies, utils 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 from core.util import logs
log = logs.get_logger("signalF") log = logs.get_logger("signalF")
@@ -263,6 +263,49 @@ class NewSignalBot(SignalBot):
await self._resolve_commands() await self._resolve_commands()
await self._produce_consume_messages() 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): def start(self):
"""Start bot without blocking the caller's event loop.""" """Start bot without blocking the caller's event loop."""
task = self._event_loop.create_task( task = self._event_loop.create_task(

View File

@@ -15,7 +15,7 @@ from django.core.cache import cache
from core.clients import ClientBase, transport from core.clients import ClientBase, transport
from core.messaging import history, media_bridge from core.messaging import history, media_bridge
from core.models import Message, PersonIdentifier from core.models import Message, PersonIdentifier, PlatformChatLink
class WhatsAppClient(ClientBase): class WhatsAppClient(ClientBase):
@@ -1514,6 +1514,7 @@ class WhatsAppClient(ClientBase):
# Read contact-like rows directly from the session sqlite DB instead. # Read contact-like rows directly from the session sqlite DB instead.
contacts, source, lid_map = await self._sync_contacts_from_sqlite() contacts, source, lid_map = await self._sync_contacts_from_sqlite()
groups, groups_source = await self._sync_groups_from_client() groups, groups_source = await self._sync_groups_from_client()
await self._upsert_groups(groups)
now_ts = int(time.time()) now_ts = int(time.time())
if contacts: if contacts:
@@ -1762,6 +1763,53 @@ class WhatsAppClient(ClientBase):
return await asyncio.to_thread(_extract) 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): async def _sync_groups_from_client(self):
if self._client is None: if self._client is None:
return [], "client_missing" return [], "client_missing"

View 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'),
),
]

View File

@@ -173,7 +173,7 @@ class PersonIdentifier(models.Model):
class PlatformChatLink(models.Model): class PlatformChatLink(models.Model):
user = models.ForeignKey(User, on_delete=models.CASCADE) 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( person_identifier = models.ForeignKey(
PersonIdentifier, PersonIdentifier,
on_delete=models.SET_NULL, on_delete=models.SET_NULL,
@@ -184,6 +184,7 @@ class PlatformChatLink(models.Model):
chat_identifier = models.CharField(max_length=255) chat_identifier = models.CharField(max_length=255)
chat_jid = models.CharField(max_length=255, blank=True, null=True) chat_jid = models.CharField(max_length=255, blank=True, null=True)
chat_name = 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) created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True) updated_at = models.DateTimeField(auto_now=True)
@@ -224,7 +225,7 @@ class PlatformChatLink(models.Model):
return super().save(*args, **kwargs) return super().save(*args, **kwargs)
def __str__(self): 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): class ChatSession(models.Model):

View File

@@ -76,7 +76,7 @@
{% if existing %} {% if existing %}
<article class="notification is-light" style="margin-top: 0.8rem;"> <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> </article>
{% endif %} {% endif %}
</div> </div>

View File

@@ -21,28 +21,47 @@
{% if contact_rows %} {% if contact_rows %}
<div class="buttons are-small" style="display: grid; gap: 0.5rem;"> <div class="buttons are-small" style="display: grid; gap: 0.5rem;">
{% for row in contact_rows %} {% for row in contact_rows %}
<button {% if row.person %}
class="button is-fullwidth" <button
style="border-radius: 8px; border: 0; background: transparent; box-shadow: none; padding: 0;" class="button is-fullwidth"
hx-get="{% url 'ai_workspace_person' type='widget' person_id=row.person.id %}" style="border-radius: 8px; border: 0; background: transparent; box-shadow: none; padding: 0;"
hx-include="#ai-window-form" hx-get="{% url 'ai_workspace_person' type='widget' person_id=row.person.id %}"
hx-target="#widgets-here" hx-include="#ai-window-form"
hx-swap="afterend"> hx-target="#widgets-here"
<span class="tags has-addons" style="display: inline-flex; width: 100%; margin: 0; white-space: nowrap;"> hx-swap="afterend">
<span class="tag is-dark" style="min-width: 2.5rem; justify-content: center;"> <span class="tags has-addons" style="display: inline-flex; width: 100%; margin: 0; white-space: nowrap;">
<i class="fa-solid fa-comment-dots" aria-hidden="true"></i> <span class="tag is-dark" style="min-width: 2.5rem; justify-content: center;">
</span> <i class="fa-solid fa-comment-dots" aria-hidden="true"></i>
<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> </span>
{% if row.last_ts_label %} <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);">
<small style="padding-left: 0.5rem;">{{ row.last_ts_label }}</small> <span style="display: inline-flex; align-items: baseline; gap: 0.35rem; min-width: 0;">
{% endif %} <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>
<span class="tag is-dark" style="min-width: 3.25rem; justify-content: center;">{{ row.message_count }}</span> </button>
</span> {% else %}
</button> <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 %} {% endfor %}
</div> </div>
{% else %} {% else %}

View File

@@ -37,6 +37,8 @@
<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 }}
{% elif group_name %}
{{ group_name }}
{% else %} {% else %}
{{ identifier }} {{ identifier }}
{% endif %} {% endif %}

View File

@@ -18,35 +18,44 @@
</thead> </thead>
{% for item in object_list %} {% for item in object_list %}
<tr> <tr>
<td>{{ item.chat.source_number }}</td> <td>{% if item.chat %}{{ item.chat.source_number }}{% endif %}</td>
<td> <td>
<a {% if item.chat %}
class="has-text-grey button nowrap-child" <a
onclick="window.prompt('Copy to clipboard: Ctrl+C, Enter', '{{ item.chat.source_uuid }}');"> class="has-text-grey button nowrap-child"
<span class="icon" data-tooltip="Copy to clipboard"> onclick="window.prompt('Copy to clipboard: Ctrl+C, Enter', '{{ item.chat.source_uuid }}');">
<i class="fa-solid fa-copy" aria-hidden="true"></i> <span class="icon" data-tooltip="Copy to clipboard">
</span> <i class="fa-solid fa-copy" aria-hidden="true"></i>
</a> </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>
<td>{{ item.chat.account }}</td>
<td>{{ item.chat.source_name }}</td>
<td>{{ item.person_name|default:"-" }}</td> <td>{{ item.person_name|default:"-" }}</td>
<td> <td>
<div class="buttons"> <div class="buttons">
<button {% if not item.is_group %}
hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}' <button
hx-delete="{# url 'account_delete' type=type pk=item.id #}" hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'
hx-trigger="click" hx-delete="{# url 'account_delete' type=type pk=item.id #}"
hx-target="#modals-here" hx-trigger="click"
hx-swap="innerHTML" hx-target="#modals-here"
hx-confirm="Are you sure you wish to unlink {{ item.chat }}?" hx-swap="innerHTML"
class="button"> hx-confirm="Are you sure you wish to unlink {{ item.chat }}?"
<span class="icon-text"> class="button">
<span class="icon"> <span class="icon-text">
<i class="fa-solid fa-xmark"></i> <span class="icon">
<i class="fa-solid fa-xmark"></i>
</span>
</span> </span>
</span> </button>
</button> {% endif %}
{% if type == 'page' %} {% if type == 'page' %}
{% if item.can_compose %} {% if item.can_compose %}
<a href="{{ item.compose_page_url }}"><button <a href="{{ item.compose_page_url }}"><button
@@ -68,16 +77,18 @@
</span> </span>
</button> </button>
{% endif %} {% endif %}
<a href="{{ item.match_url }}"><button {% if not item.is_group %}
class="button" <a href="{{ item.match_url }}"><button
title="Match identifier to person"> class="button"
<span class="icon-text"> title="Match identifier to person">
<span class="icon"> <span class="icon-text">
<i class="fa-solid fa-link"></i> <span class="icon">
<i class="fa-solid fa-link"></i>
</span>
</span> </span>
</span> </button>
</button> </a>
</a> {% endif %}
<a href="{{ item.ai_url }}"><button <a href="{{ item.ai_url }}"><button
class="button" class="button"
title="Open AI workspace"> title="Open AI workspace">
@@ -112,13 +123,15 @@
</span> </span>
</button> </button>
{% endif %} {% endif %}
<a href="{{ item.match_url }}"><button class="button" title="Match identifier to person"> {% if not item.is_group %}
<span class="icon-text"> <a href="{{ item.match_url }}"><button class="button" title="Match identifier to person">
<span class="icon"> <span class="icon-text">
<i class="fa-solid fa-link"></i> <span class="icon">
<i class="fa-solid fa-link"></i>
</span>
</span> </span>
</span> </button></a>
</button></a> {% endif %}
<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">

View File

@@ -14,7 +14,7 @@
</thead> </thead>
{% for item in object_list %} {% for item in object_list %}
<tr> <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> <td>
<a <a
class="has-text-grey button nowrap-child" class="has-text-grey button nowrap-child"

View File

@@ -38,6 +38,7 @@ from core.models import (
PatternMitigationPlan, PatternMitigationPlan,
Person, Person,
PersonIdentifier, PersonIdentifier,
PlatformChatLink,
WorkspaceConversation, WorkspaceConversation,
) )
from core.realtime.typing_state import get_person_typing_state 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 identifier = person_identifier.identifier
person = person_identifier.person 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 { return {
"person_identifier": person_identifier, "person_identifier": person_identifier,
"service": service, "service": service,
"identifier": identifier, "identifier": identifier,
"person": person, "person": person,
"is_group": False,
"group_name": "",
} }
@@ -2095,6 +2116,8 @@ def _panel_context(
"typing_state_json": json.dumps(typing_state), "typing_state_json": json.dumps(typing_state),
"platform_options": platform_options, "platform_options": platform_options,
"recent_contacts": recent_contacts, "recent_contacts": recent_contacts,
"is_group": base.get("is_group", False),
"group_name": base.get("group_name", ""),
} }

View File

@@ -9,7 +9,7 @@ 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 Chat, PersonIdentifier from core.models import Chat, PersonIdentifier, PlatformChatLink
from core.views.manage.permissions import SuperUserRequiredMixin 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 return rows

View File

@@ -7,7 +7,7 @@ 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.models import PersonIdentifier, PlatformChatLink
from core.util import logs from core.util import logs
from core.views.compose import _compose_urls, _service_icon_class from core.views.compose import _compose_urls, _service_icon_class
from core.views.manage.permissions import SuperUserRequiredMixin from core.views.manage.permissions import SuperUserRequiredMixin
@@ -288,7 +288,10 @@ class WhatsAppChatsList(WhatsAppContactsList):
identifier = str(key or "").strip() identifier = str(key or "").strip()
if not identifier: if not identifier:
continue continue
if "@newsletter" in identifier:
continue
identifier = identifier.split("@", 1)[0].strip() or identifier identifier = identifier.split("@", 1)[0].strip() or identifier
identifier = identifier.lstrip("+")
if identifier in seen: if identifier in seen:
continue continue
seen.add(identifier) seen.add(identifier)
@@ -327,50 +330,88 @@ class WhatsAppChatsList(WhatsAppContactsList):
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 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) db_group_ids = set()
for item in combined_contacts: db_groups = PlatformChatLink.objects.filter(
identifier = str( user=self.request.user,
item.get("identifier") or item.get("jid") or item.get("chat") or "" service="whatsapp",
).strip() is_group=True,
if not identifier: )
for link in db_groups:
bare_id = str(link.chat_identifier or "").strip()
if not bare_id:
continue continue
identifier = identifier.split("@", 1)[0].strip() db_group_ids.add(bare_id)
if not identifier or identifier in seen: if bare_id not in seen:
continue seen.add(bare_id)
seen.add(identifier) jid = link.chat_jid or f"{bare_id}@g.us"
jid = str(item.get("jid") or "").strip() urls = _compose_urls("whatsapp", bare_id, None)
linked = self._linked_identifier(identifier, jid) rows.append(
urls = _compose_urls( {
"whatsapp", "identifier": bare_id,
identifier, "jid": jid,
linked.person_id if linked else None, "name": link.chat_name or bare_id,
) "is_group": True,
name = ( "service_icon_class": _service_icon_class("whatsapp"),
str(item.get("name") or item.get("chat") or "").strip() "person_name": "",
or (linked.person.name if linked else "") "compose_page_url": urls["page_url"],
or jid "compose_widget_url": urls["widget_url"],
or identifier "match_url": "",
or "WhatsApp Chat" "last_ts": int(link.updated_at.timestamp()),
) }
rows.append( )
{
"identifier": identifier, for row in rows:
"jid": jid or identifier, if not row.get("is_group") and row.get("identifier") in db_group_ids:
"name": name, row["is_group"] = True
"service_icon_class": _service_icon_class("whatsapp"),
"person_name": linked.person.name if linked else "", return [row for row in rows if row.get("is_group")]
"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
class WhatsAppAccountAdd(SuperUserRequiredMixin, ObjectRead): class WhatsAppAccountAdd(SuperUserRequiredMixin, ObjectRead):

View File

@@ -34,6 +34,7 @@ from core.models import (
PatternMitigationRule, PatternMitigationRule,
Person, Person,
PersonIdentifier, PersonIdentifier,
PlatformChatLink,
QueuedMessage, QueuedMessage,
WorkspaceConversation, WorkspaceConversation,
WorkspaceMetricSnapshot, WorkspaceMetricSnapshot,
@@ -3538,6 +3539,24 @@ class AIWorkspaceContactsWidget(LoginRequiredMixin, View):
} }
) )
rows.sort(key=lambda row: row["last_ts"] or 0, reverse=True) 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 return rows
def get(self, request, type): def get(self, request, type):

View File

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

View File

@@ -33,6 +33,9 @@ require_podman() {
ensure_dirs() { ensure_dirs() {
mkdir -p "$REDIS_DATA_DIR" "$WHATSAPP_DATA_DIR" "$VRUN_DIR" "$ROOT_DIR/signal-cli-config" 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() { rm_if_exists() {