Files
chatballs/apps/backend/chatballs/conversations/models.py
T
AndreyandClaude Opus 5 8d334de3f3 🐛 fix: завершение звонка, фото контактов и голосовые из MAX
Кнопка «Завершить» у оператора обязана заканчивать звонок из любой живой фазы. Отмена умела только REQUESTED/RINGING, и после того как клиент принял вызов, а соединение не установилось (обычное дело за NAT), оператор получал 409 и звонок висел. Фазу выбирает finish_call — тот же код, что и на стороне клиента; в интерфейсе кнопка больше не молчит при ошибке, а заканчивает звонок вторым путём.

Фото контакта из Telegram и MAX скачивается и хранится у нас, а отдаётся своим адресом: страница рабочего места живёт под CSP «img-src 'self'», и ссылка на CDN мессенджера до экрана не доезжала — оператор видел инициалы. Источник запоминается, поэтому фото качается один раз; у Telegram оно спрашивается отдельным запросом, которого в апдейте нет.

Голосовое из MAX с незнакомой формой вложения больше не пропадает: раньше такое сообщение уходило в никуда, теперь оператор видит его заглушкой, а в журнал попадает сам payload. В журнал же пишется причина, по которой диалог сразу уходит в очередь: у канала нет активного AI-агента.

Проверено: 83 теста звонков, 6 новых тестов фото контакта, тесты ingest, вложений и голосовых, typecheck рабочего места.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 00:44:35 +03:00

442 lines
25 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
from django.conf import settings
from django.contrib.postgres.indexes import GinIndex
from django.contrib.postgres.search import SearchVector
from django.db import models
from django.utils import timezone
from chatballs.i18n import t
from chatballs.tenancy.models import TenantRelationModel
# Минимальный домен диалогов (ADR-HUB-0001/0002/0003/0006). Состояние диалога
# разделено на независимые оси; перехват оператором — атомарный.
def contact_avatar_upload_path(instance: "Contact", filename: str) -> str:
import uuid
from pathlib import Path
suffix = Path(filename).suffix.lower()[:8] or ".jpg"
return f"organizations/{instance.organization.public_id}/contacts/{uuid.uuid4().hex}{suffix}"
class Contact(models.Model):
organization = models.ForeignKey("identity.Organization", on_delete=models.PROTECT, related_name="contacts")
name = models.CharField(max_length=255, blank=True)
# Телефон приходит только через явный шаринг контакта (кнопка в TG/MAX,
# форма в веб-чате) — автоматически мессенджеры его не отдают.
phone = models.CharField(max_length=32, blank=True)
# Внешний URL аватара контакта, если провайдер его отдаёт (например, MAX
# присылает avatar_url в профиле отправителя). Telegram не отдаёт фото в
# getUpdates, поэтому для него поле остаётся пустым. Хранится только URL —
# само изображение живёт на стороне провайдера.
avatar_url = models.URLField(max_length=512, blank=True, default="")
# Фото контакта, скачанное у провайдера и лежащее у нас. Внешней ссылкой
# обойтись нельзя: страница рабочего места живёт под CSP `img-src 'self'`,
# и картинка с чужого домена до экрана не доезжает — оператор видит
# инициалы вместо фото. Источник запоминается, чтобы не качать то же самое
# на каждое сообщение.
avatar = models.FileField(
upload_to=contact_avatar_upload_path, max_length=512, blank=True, default=""
)
avatar_content_type = models.CharField(max_length=64, blank=True, default="")
avatar_source = models.CharField(max_length=512, blank=True, default="")
# Карточка контакта (дизайн-базлайн v2, решение 5): описание, компания, город —
# заполняет оператор.
description = models.TextField(blank=True, default="")
company = models.CharField(max_length=160, blank=True, default="")
city = models.CharField(max_length=120, blank=True, default="")
# Контакт, в который этот был объединён (ADR-CHATBALLS-0006). Строка не удаляется:
# объединение обратимо, поэтому исходный контакт остаётся для разъединения.
merged_into = models.ForeignKey(
"self",
on_delete=models.SET_NULL,
related_name="merged_contacts",
null=True,
blank=True,
)
created_at = models.DateTimeField(auto_now_add=True)
# Триграммные индексы для поиска подстрокой (имя, телефон) заведены в
# миграции 0020 сырым SQL: Django 5.2 рендерит функциональный индекс с
# opclass без внутренних скобок — «(UPPER(name) gin_trgm_ops)», и Postgres
# такой синтаксис не принимает.
def __str__(self) -> str:
return self.name or f"contact:{self.id}"
class ContactMerge(models.Model):
"""Журнал объединения контактов (ADR-CHATBALLS-0006).
Хранит, что именно переехало, чтобы объединение можно было развернуть
обратно: перенесённые идентичности и диалоги и поля карточки, которые были
заполнены из исходного контакта.
"""
organization = models.ForeignKey("identity.Organization", on_delete=models.PROTECT, related_name="contact_merges")
target = models.ForeignKey(Contact, on_delete=models.PROTECT, related_name="merges_in")
source = models.ForeignKey(Contact, on_delete=models.PROTECT, related_name="merges_out")
reason = models.TextField()
actor = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, blank=True, related_name="+")
moved_identity_ids = models.JSONField(default=list, blank=True)
moved_conversation_ids = models.JSONField(default=list, blank=True)
filled_fields = models.JSONField(default=list, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
reverted_at = models.DateTimeField(null=True, blank=True)
reverted_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, blank=True, related_name="+")
revert_reason = models.TextField(blank=True, default="")
class Meta:
ordering = ["-created_at"]
def __str__(self) -> str:
return f"merge:{self.source_id}->{self.target_id}"
class ConnectionIdentity(TenantRelationModel):
tenant_relation_fields = ("contact", "connection")
# Устойчивая идентичность контакта внутри конкретного подключения (ADR-CHATBALLS-0006).
contact = models.ForeignKey(Contact, on_delete=models.CASCADE, related_name="identities")
connection = models.ForeignKey("integrations.Integration", on_delete=models.PROTECT, related_name="identities")
external_user_id = models.CharField(max_length=128)
display_name = models.CharField(max_length=255, blank=True)
# Публичный логин в мессенджере (@username в TG/MAX); пустой, если не задан.
username = models.CharField(max_length=128, blank=True)
# Когда подключение отдало подтверждённый телефон (кнопка «поделиться
# контактом»). Только такая идентичность считается подтверждённой
# (ADR-CHATBALLS-0006) — колонка «Статус» на вкладке «Идентификаторы».
phone_verified_at = models.DateTimeField(null=True, blank=True, db_default=None)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
constraints = [
models.UniqueConstraint(fields=["connection", "external_user_id"], name="uniq_identity_connection_user"),
]
def __str__(self) -> str:
return f"{self.connection_id}:{self.external_user_id}"
class LifecycleState(models.TextChoices):
OPEN = "OPEN", "Открыт"
CLOSED = "CLOSED", "Закрыт"
SPAM = "SPAM", "Спам"
class ConversationPriority(models.TextChoices):
# Приоритет диалога (дизайн-базлайн v2, решение владельца 2026-09-04).
HIGH = "HIGH", "Высокий"
MEDIUM = "MEDIUM", "Средний"
LOW = "LOW", "Низкий"
NONE = "NONE", "Не задан"
class ConversationLabel(models.Model):
"""Метка диалога: цветной чип, общий словарь организации."""
organization = models.ForeignKey(
"identity.Organization", on_delete=models.PROTECT, related_name="conversation_labels"
)
name = models.CharField(max_length=60)
# HEX-цвет чипа; палитру предлагает клиент.
color = models.CharField(max_length=20, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
ordering = ["name"]
constraints = [
models.UniqueConstraint(
models.functions.Lower("name"), "organization",
name="uniq_conversation_label_org_name_ci",
)
]
def __str__(self) -> str:
return f"label:{self.organization_id}/{self.name}"
class ControlMode(models.TextChoices):
AI = "AI", "AI"
HUMAN = "HUMAN", "Оператор"
PAUSED = "PAUSED", "Пауза"
class ExpectedResponder(models.TextChoices):
CUSTOMER = "CUSTOMER", "Клиент"
AI = "AI", "AI"
OPERATOR = "OPERATOR", "Оператор"
NOBODY = "NOBODY", "Никто"
class Conversation(models.Model):
organization = models.ForeignKey("identity.Organization", on_delete=models.PROTECT, related_name="conversations")
channel = models.ForeignKey("channels.Channel", on_delete=models.PROTECT, related_name="conversations")
connection = models.ForeignKey("integrations.Integration", on_delete=models.PROTECT, related_name="conversations", null=True, blank=True)
# Единственный источник identity диалога — контакт. Авторизованный
# in-product клиент (SupportIdentitySnapshot) удалён вместе с сущностью
# Product (ADR-CHATBALLS-0045).
contact = models.ForeignKey(Contact, on_delete=models.PROTECT, related_name="conversations", null=True, blank=True)
# Внешний идентификатор чата (для отправки ответа в канал).
external_chat_id = models.CharField(max_length=128, blank=True)
# Транспортная мета диалога (ADR-CHATBALLS-0035): для email — тема исходного
# письма и Message-ID последнего входящего (тредирование Re:/In-Reply-To).
transport_meta = models.JSONField(default=dict, blank=True)
lifecycle = models.CharField(max_length=16, choices=LifecycleState.choices, default=LifecycleState.OPEN)
control_mode = models.CharField(max_length=16, choices=ControlMode.choices, default=ControlMode.AI)
expected_responder = models.CharField(max_length=16, choices=ExpectedResponder.choices, default=ExpectedResponder.AI)
# С какого момента диалог ждёт человека (chatballs.conversations.queue).
# Не «последнее сообщение»: клиент, написавший повторно, ждёт не меньше, а
# больше прежнего, и в очереди обязан оставаться выше, а не ниже.
waiting_since = models.DateTimeField(null=True, blank=True)
# Группа видимости (ADR-CHATBALLS-0043): наследуется от group агента/канала при
# создании, переносится вручную. NULL — диалог виден всем сотрудникам.
group = models.ForeignKey(
"identity.EmployeeGroup",
on_delete=models.SET_NULL,
related_name="conversations",
null=True,
blank=True,
)
# «Ответственный» (ADR-CHATBALLS-0043): видит диалог независимо от групп.
assigned_operator = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, blank=True, related_name="assigned_conversations")
# Когда назначили. С этого момента идёт срок личной очереди: не взял —
# диалог возвращается в общую (chatballs.conversations.escalation).
assigned_at = models.DateTimeField(null=True, blank=True)
# Дизайн-базлайн v2: приоритет, метки и заметка оператора.
priority = models.CharField(
max_length=8, choices=ConversationPriority.choices, default=ConversationPriority.NONE
)
labels = models.ManyToManyField(ConversationLabel, blank=True, related_name="conversations")
note = models.TextField(blank=True)
# Кто и когда оставил заметку — подпись «Анна Ким · 2 сен» на карточке
# контакта (дизайн-базлайн v2, кадр K3).
note_author = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="+",
)
note_updated_at = models.DateTimeField(null=True, blank=True, db_default=None)
# «Удалить диалог» = архив (решение владельца): скрыт из списков, видят
# только администраторы; данные не удаляются.
archived_at = models.DateTimeField(null=True, blank=True)
previous_conversation = models.ForeignKey("self", on_delete=models.SET_NULL, null=True, blank=True, related_name="+")
created_at = models.DateTimeField(auto_now_add=True)
last_activity_at = models.DateTimeField(auto_now_add=True, db_index=True)
# Время последнего сообщения — порядок инбокса. Отдельно от last_activity_at,
# который двигают и служебные действия (перехват, возврат AI): по нему список
# сортировать нельзя. У диалога без сообщений — время создания, поэтому поле
# непустое и по нему работает и индекс, и курсор окна (api.pagination).
last_message_at = models.DateTimeField(default=timezone.now)
class Meta:
ordering = ["-last_activity_at"]
indexes = [
models.Index(fields=["channel", "lifecycle"]),
# Порядок инбокса: организация → свежесть. Раньше сортировка шла по
# агрегату max(messages.created_at) и в индекс не ложилась.
models.Index(
fields=["organization", "-last_message_at", "-id"], name="conv_inbox_order"
),
# Список контактов спрашивает по каждому контакту его свежий диалог
# и число открытых — подзапросами по этой паре.
models.Index(
fields=["contact", "-last_activity_at"], name="conv_contact_recent"
),
# Очередь к оператору: кто ждёт дольше всех и не дождался порога.
models.Index(
fields=["organization", "waiting_since"], name="conv_waiting_order"
),
]
constraints = [
# Диалог всегда принадлежит контакту.
models.CheckConstraint(
condition=models.Q(contact__isnull=False),
name="conversation_requires_contact",
),
]
def __str__(self) -> str:
return f"conv:{self.id}/{self.lifecycle}/{self.control_mode}"
class ConversationRead(TenantRelationModel):
"""Персональная отметка прочтения диалога: до какого сообщения дочитал
сотрудник. Обновляется при открытии диалога; бейдж непрочитанных в списке
считается относительно этой отметки."""
tenant_relation_fields = ("conversation",)
conversation = models.ForeignKey(Conversation, on_delete=models.CASCADE, related_name="reads")
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="conversation_reads")
last_read_message_id = models.BigIntegerField(default=0)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
constraints = [models.UniqueConstraint(fields=["conversation", "user"], name="uniq_conversation_read")]
def __str__(self) -> str:
return f"read:{self.conversation_id}/{self.user_id}@{self.last_read_message_id}"
class MessageAuthor(models.TextChoices):
CONTACT = "CONTACT", "Клиент"
AI = "AI", "AI"
OPERATOR = "OPERATOR", "Оператор"
SYSTEM = "SYSTEM", t("admin.actor_system")
class SystemEvent(models.TextChoices):
"""Код системного события диалога.
Текст события раньше писался в ``text`` по-русски и оставался таким
навсегда: история — записи, а не подписи, и перевести её задним числом
нельзя. Поэтому в базу идёт код, а фразу собирает интерфейс на языке того,
кто её читает. ``text`` продолжает заполняться: он остаётся и запасным
вариантом для строк, записанных до этого поля, и тем, что видно в базе
глазами.
"""
OPERATOR_TOOK = "operator_took", "Оператор перехватил диалог"
RETURNED_TO_AI = "returned_to_ai", "Диалог возвращён AI"
RETURNED_TO_QUEUE = "returned_to_queue", "Диалог возвращён в очередь"
AI_UNAVAILABLE = "ai_unavailable", "AI недоступен"
AI_HANDED_OVER = "ai_handed_over", "AI передал диалог оператору"
ASSIGNED_TO = "assigned_to", "Диалог назначен сотруднику"
ASSIGNMENT_EXPIRED = "assignment_expired", "Назначение истекло"
CALL_REQUESTED = "call_requested", "Запрошен звонок"
CALL_ACCEPTED = "call_accepted", "Клиент принял приглашение"
CALL_DECLINED = "call_declined", "Клиент отклонил приглашение"
CALL_CANCELLED = "call_cancelled", "Приглашение отменено"
CALL_MISSED = "call_missed", "Звонок пропущен"
CALL_EXPIRED = "call_expired", "Приглашение истекло"
CALL_STARTED = "call_started", "Звонок начался"
CALL_ENDED = "call_ended", "Звонок завершён"
CALL_FAILED = "call_failed", "Звонок не состоялся"
class MessageKind(models.TextChoices):
TEXT = "", "Текст"
CONTACT_REQUEST = "contact_request", "Запрос контакта"
CONTACT = "contact", "Контакт"
VOICE = "voice", "Голосовое сообщение"
FILE = "file", "Файл"
class TranscriptStatus(models.TextChoices):
# Расшифровка голосового (дизайн-базлайн v2, кадр H): по кнопке, через
# BYOK-провайдера организации (решение владельца 2026-09-04).
NONE = "NONE", "Не расшифровано"
READY = "READY", "Готова"
FAILED = "FAILED", "Ошибка"
def message_audio_upload_path(instance: "Message", filename: str) -> str:
import uuid
from pathlib import Path
suffix = Path(filename).suffix.lower() or ".ogg"
organization = instance.conversation.organization
return f"organizations/{organization.public_id}/voice/{uuid.uuid4().hex}{suffix}"
def message_attachment_upload_path(instance: "Message", filename: str) -> str:
import uuid
from pathlib import Path
suffix = Path(filename).suffix.lower()[:16]
organization = instance.conversation.organization
return f"organizations/{organization.public_id}/files/{uuid.uuid4().hex}{suffix}"
class Message(TenantRelationModel):
tenant_relation_fields = ("conversation",)
conversation = models.ForeignKey(Conversation, on_delete=models.CASCADE, related_name="messages")
author_type = models.CharField(max_length=16, choices=MessageAuthor.choices)
author_user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, blank=True, related_name="+")
# Тип сообщения: обычный текст, запрос контакта (веб-виджет рисует форму
# телефона), полученный контакт. Пустая строка = текст.
kind = models.CharField(max_length=32, choices=MessageKind.choices, default=MessageKind.TEXT, blank=True)
text = models.TextField(blank=True)
# Системное событие: код и его параметры (имя оператора, длительность).
# Пустой код — обычное сообщение либо системная запись, сделанная до
# появления поля; такие показываются по сохранённому тексту.
system_event = models.CharField(max_length=32, choices=SystemEvent.choices, blank=True, default="")
system_params = models.JSONField(default=dict, blank=True)
# Санитизированный HTML входящего email. Остальные транспорты и исходящие
# ответы используют plain text.
content_html = models.TextField(blank=True)
# Голосовое сообщение (kind=VOICE): аудиофайл, длительность и расшифровка.
audio = models.FileField(upload_to=message_audio_upload_path, max_length=512, blank=True)
audio_content_type = models.CharField(max_length=64, blank=True)
duration_seconds = models.PositiveIntegerField(default=0)
transcript = models.TextField(blank=True)
transcript_status = models.CharField(
max_length=8, choices=TranscriptStatus.choices, default=TranscriptStatus.NONE
)
# Файл/фото (kind=FILE): вложение с исходным именем, типом и размером.
attachment = models.FileField(upload_to=message_attachment_upload_path, max_length=512, blank=True)
attachment_name = models.CharField(max_length=255, blank=True)
attachment_content_type = models.CharField(max_length=128, blank=True)
attachment_size = models.PositiveBigIntegerField(default=0)
external_id = models.CharField(max_length=128, blank=True)
created_at = models.DateTimeField(auto_now_add=True, db_index=True)
class Meta:
ordering = ["created_at"]
indexes = [
# Полнотекстовый поиск по сообщениям (поиск в списке диалогов).
#
# Конфигурация «russian» покрывает обе переписки, и второй индекс
# под английский был бы тратой места: в ней asciiword отдан
# english_stem, а word — russian_stem, поэтому английские слова
# стеммятся английским стеммером, а русские русским. Обратное
# неверно: «english» оставляет кириллицу без основы. Проверено
# тестом conversations/test_search_language.py.
GinIndex(
SearchVector("text", config="russian"),
name="conv_message_text_fts",
),
# Окно истории идёт ровно по этому ключу: диалог → время → id.
models.Index(
fields=["conversation", "created_at", "id"], name="conv_message_window"
),
]
def __str__(self) -> str:
return f"msg:{self.conversation_id}/{self.author_type}"
class ReplyTemplate(models.Model):
"""Шаблон ответа оператора («/» в композере). Общий на организацию:
редактируют администраторы, используют все сотрудники."""
organization = models.ForeignKey(
"identity.Organization", on_delete=models.PROTECT, related_name="reply_templates"
)
title = models.CharField(max_length=120)
text = models.TextField()
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
ordering = ["title"]
constraints = [
models.UniqueConstraint(
models.functions.Lower("title"), "organization",
name="uniq_reply_template_org_title_ci",
)
]
def __str__(self) -> str:
return f"template:{self.organization_id}/{self.title}"
# Django импортирует только models.py: пороги очереди лежат рядом, чтобы не
# растить этот файл, и переэкспортируются здесь ради регистрации модели.
from chatballs.conversations.queue_models import ( # noqa: E402, F401
QueueEscalationPolicy,
policy_for,
)