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

View File

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

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):
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):

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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() {
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() {