Compare commits

...
13 Commits
Author SHA1 Message Date
Andrey Ermolaev d5c9d5c638 Merge pull request #4 from dartdavros/codex/release-1.15.2-cleanup
Подготовка релиза v1.15.2: очистка устаревших терминов
2026-09-24 18:03:41 +03:00
Andrey 7e0a12a2c9 ♻️ refactor: убрать устаревшие упоминания Hub и CustoAI 2026-09-24 18:02:27 +03:00
Andrey Ermolaev fc370fafe1 Merge pull request #3 from dartdavros/codex/help-center-navigation-related
Навигация и связанные статьи портала помощи
2026-09-24 14:09:33 +03:00
Andrey df9c6ad6fd 🚨 fix(lint): порядок импортов портала 2026-09-24 14:08:04 +03:00
Andrey 8c618691f6 ✨ feat(portals): навигация и связанные статьи 2026-09-24 14:05:21 +03:00
AndreyandClaude Opus 5.5 f57b1ee54a 🔖 release: 1.15.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 10:05:44 +03:00
AndreyandClaude Opus 5.5 64266dcc9c ✨ feat(settings): шаблоны ответов в настройках и переменные в тексте
Раздел «Настройки → Шаблоны ответов»: список по образцу «Групп», создание
кнопкой в шапке раздела, правка и удаление окнами, как у интеграций. Без права
на настройки список только читается.

В тексте шаблона можно использовать {{client_name}}, {{operator_name}} и
{{company}}; кнопка «Вставить переменную» ставит код в позицию курсора.
Композер подставляет значения при выборе шаблона. Переменная без значения
остаётся в тексте, над полем — предупреждение, отправка заблокирована. Гость
виджета распознаётся по подписи сессии (isGuest в карточке диалога), его
«Гость · код» за имя не считается. Сервер отклоняет неизвестные переменные.

Переименование шаблона в занятое название теперь отвечает 409, а не падает
на ограничении базы. Логика шаблонов вынесена из Composer в
useComposerTemplates и ComposerTemplatesMenu.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 10:05:32 +03:00
Andrey cce87a4973 🔖 release: 1.14.3 2026-09-22 07:29:25 +03:00
Andrey 556246865e 🚨 fix(lint): исправить сортировку импортов 2026-09-22 07:29:03 +03:00
Andrey 0aff0c564b 🔖 release: 1.14.2 2026-09-22 07:13:46 +03:00
Andrey 0d307c7d06 🐛 fix(ai): сбрасывать circuit breaker после настройки провайдера
Повторы одного вызова теперь считаются одним логическим сбоем, поэтому предохранитель не открывается посреди второго сообщения. Изменение runtime-конфигурации и успешная проверка увеличивают ревизию интеграции: каждый event-worker заменяет открытый breaker при следующем запросе.

Удаление используемого провайдера возвращает 409 с понятной причиной и не пишет ложное событие об успешном удалении. Добавлены миграция и регрессионные тесты.
2026-09-22 07:13:20 +03:00
AndreyandClaude Opus 5 4e56b10e6a 🔖 release: 1.14.1
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-20 10:49:00 +03:00
AndreyandClaude Opus 5 bcb66fadbc 🐛 fix(webchat): первое открытие чата — с анимацией и кнопкой «свернуть»
Первый кадр силуэта рисовался раньше, чем создавался слой, на котором он
живёт. Обращение к нему падало, и остаток обработчика не выполнялся: окно
появлялось рывком, а кнопка оставалась со знаком агента вместо шеврона —
свернуть чат было нечем. Со второго раза слой уже существовал, и всё
работало, поэтому дефект был виден только при первом открытии.

Слой создаётся вместе с панелью, до первого кадра.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-20 10:49:00 +03:00
127 changed files with 2014 additions and 300 deletions

No files matched your search

+1 -1
View File
@@ -33,7 +33,7 @@ UI / дизайн:
- Перед тем как написать свой элемент, искать существующий в `shared/` и в соседних фичах. Второй стандарт того же элемента — это дефект, а не свобода реализации.
Никаких служебных записок в продукте:
- В продуктовом UI не должно быть служебной лексики. Запрещены на экране: коды спек и решений (SPEC-HUB-*, ADR-HUB-*, DG-*), номера правил (P1-P5 и любые другие), слова «инвариант», «констрейнт», «миграция», «scope» в техническом смысле, имена таблиц, полей и enum-значений БД и API (`allowCheckoutActions`, `Conversation.channel`, `PROTECT`, `OK`/`ERROR` как есть), ссылки на внутренние документы и любые пометки для разработчика.
- В продуктовом UI не должно быть служебной лексики. Запрещены на экране: коды спек и решений (SPEC-*, ADR-*, DG-*), номера правил (P1-P5 и любые другие), слова «инвариант», «констрейнт», «миграция», «scope» в техническом смысле, имена таблиц, полей и enum-значений БД и API (`allowCheckoutActions`, `Conversation.channel`, `PROTECT`, `OK`/`ERROR` как есть), ссылки на внутренние документы и любые пометки для разработчика.
- Пользователю показывается следствие и способ исправить, а не внутреннее правило. «Коммерческие действия недоступны непродуктовому каналу. Назначьте продукт» — да. «Запрещено инвариантом P1» — нет.
- Технические коды остаются в коде, комментариях, логах и API-ответах, но не в текстах интерфейса.
- Это относится и к baseline-макетам: служебный текст, попавший в макет, не является основанием выводить его на экран. Макет реализуется, дополняется по указанию владельца, служебная лексика в реализацию не переносится.
+1 -1
View File
@@ -1,4 +1,4 @@
# Caddyfile — единый HTTP/HTTPS public boundary Chatballs (ADR-HUB-0028 §gateway).
# Caddyfile — единый HTTP/HTTPS public boundary Chatballs (ADR-CHATBALLS-0028 §gateway).
# Подставляется в release bundle и монтируется в контейнер gateway.
#
# Свежая установка не знает своего домена: человек поднимает докер на сервере и
+1 -1
View File
@@ -1 +1 @@
1.14.0
1.15.0
+1 -1
View File
@@ -22,7 +22,7 @@ COPY apps/backend /app/apps/backend
COPY deploy/secrets/generate-instance-secrets.sh /usr/local/bin/chatballs-generate-secrets.sh
RUN chmod 0755 /usr/local/bin/chatballs-generate-secrets.sh
# collectstatic в образе (ADR-HUB-0028): STATIC_ROOT испечён, runtime-шаг не нужен.
# collectstatic в образе (ADR-CHATBALLS-0028): STATIC_ROOT испечён, runtime-шаг не нужен.
# Build-time secret нужен только чтобы settings загрузились в production-режиме;
# collectstatic не обращается к БД/Redis/S3. whitenoise раздаёт static в runtime.
# Build-time dummy values satisfy C04 runtime guards (distinct DB users in
@@ -228,7 +228,7 @@ class AgentCardDeactivateView(_AgentCardStatusView):
class AgentCardTestChatView(APIView):
permission_classes = [HasCapability]
# Исполняет агента, а не изменяет канал: остаётся на ai.manage (ADR-HUB-0037 §9).
# Исполняет агента, а не изменяет канал: остаётся на ai.manage.
required_capability = "ai.manage"
def post(self, request: Request, agent_id: int) -> Response:
+28 -14
View File
@@ -31,18 +31,25 @@ from chatballs.ai.provider.base import (
from chatballs.ai.provider.factory import get_provider
from chatballs.ai.provider.resilience import CircuitBreaker, call_with_resilience
# Предохранитель считает сбои по ключу «организация + интеграция»: провайдер у
# каждой организации свой, и отозванный ключ одной не имеет отношения к AI
# остальных. Общий на процесс предохранитель гасил AI у всех сразу.
_breakers: dict[tuple[int, int], CircuitBreaker] = {}
@dataclass(slots=True)
class _BreakerSlot:
revision: int
breaker: CircuitBreaker
def _breaker(key: tuple[int, int]) -> CircuitBreaker:
breaker = _breakers.get(key)
if breaker is None:
breaker = CircuitBreaker()
_breakers[key] = breaker
return breaker
_breakers: dict[tuple[int, int], _BreakerSlot] = {}
def _breaker(key: tuple[int, int], revision: int) -> CircuitBreaker:
slot = _breakers.get(key)
if slot is None or slot.revision != revision:
slot = _BreakerSlot(revision=revision, breaker=CircuitBreaker())
_breakers[key] = slot
return slot.breaker
def reset_breakers() -> None:
@@ -51,13 +58,14 @@ def reset_breakers() -> None:
_breakers.clear()
def _breaker_key(channel) -> tuple[int, int]:
def _breaker_identity(channel) -> tuple[tuple[int, int], int]:
"""Ключ предохранителя. Без канала провайдер может быть только тестовым —
считать сбои там не по чему, и общий ключ (0, 0) никому не мешает."""
if channel is None:
return (0, 0)
return (channel.organization_id, routing.integration_id(channel))
return (0, 0), 0
integration_id, revision = routing.integration_runtime_identity(channel)
return (channel.organization_id, integration_id), revision
def _elapsed_ms(started: float) -> int:
@@ -72,6 +80,7 @@ class ChatJob:
model: str
messages: list[ChatMessage]
breaker_key: tuple[int, int]
breaker_revision: int
params: dict | None = None
@@ -83,6 +92,7 @@ class EmbeddingJob:
model: str
texts: list[str]
breaker_key: tuple[int, int]
breaker_revision: int
def _effective_model(channel, requested_model: str | None) -> str:
@@ -109,11 +119,13 @@ def prepare_chat(
) -> ChatJob:
"""Шаг в транзакции: провайдер, модель и очищенный от ПДн текст запроса."""
breaker_key, breaker_revision = _breaker_identity(channel)
return ChatJob(
provider=get_provider(channel=channel, timeout=timeout),
model=_effective_model(channel, model),
messages=[ChatMessage(role=item.role, content=redact(item.content)) for item in messages],
breaker_key=_breaker_key(channel),
breaker_key=breaker_key,
breaker_revision=breaker_revision,
params=params,
)
@@ -124,7 +136,7 @@ def run_chat(job: ChatJob) -> ChatResult:
return call_with_resilience(
lambda: job.provider.chat(messages=job.messages, model=job.model, params=job.params),
retries=settings.CHATBALLS_AI_MAX_RETRIES,
breaker=_breaker(job.breaker_key),
breaker=_breaker(job.breaker_key, job.breaker_revision),
)
@@ -200,11 +212,13 @@ def prepare_embedding(
) -> EmbeddingJob:
"""Шаг в транзакции: провайдер эмбеддингов организации."""
breaker_key, breaker_revision = _breaker_identity(channel)
return EmbeddingJob(
provider=get_provider(channel=channel, timeout=timeout),
model=model,
texts=texts,
breaker_key=_breaker_key(channel),
breaker_key=breaker_key,
breaker_revision=breaker_revision,
)
@@ -214,7 +228,7 @@ def run_embedding(job: EmbeddingJob) -> list[EmbeddingResult]:
return call_with_resilience(
lambda: job.provider.embed(texts=job.texts, model=job.model),
retries=settings.CHATBALLS_AI_MAX_RETRIES,
breaker=_breaker(job.breaker_key),
breaker=_breaker(job.breaker_key, job.breaker_revision),
)
@@ -1,4 +1,4 @@
# Generated for CustoAI / BYOK credential mode (ADR-HUB-0033 §4, SPEC-CHATBALLS-0024 §2).
# Историческое поле режима доступа к AI; удалено в 0016.
from django.db import migrations, models
@@ -14,8 +14,8 @@ class Migration(migrations.Migration):
model_name='aiagent',
name='credential_mode',
field=models.CharField(
choices=[('CUSTOAI', 'CustoAI (Managed)'), ('BYOK', 'BYOK')],
default='CUSTOAI',
choices=[('BYOK', 'BYOK')],
default='BYOK',
max_length=16,
),
),
@@ -1,4 +1,4 @@
# SPEC-HUB-0027 §9, ADR-HUB-0037 §8 — этап 5, шаги 1-2.
# Провайдер LLM переезжает с канала на агента.
#
# BYOK-секрет переезжает с канала на агента. До сих пор credential_mode и model
# жили на AIAgent, а provider_integration — на Channel: одно решение было
@@ -15,7 +15,7 @@ def backfill_agents(apps, schema_editor):
channel=channel,
name=channel.name,
status="DRAFT",
credential_mode="CUSTOAI",
credential_mode="BYOK",
)
@@ -1,5 +1,4 @@
# ADR-CHATBALLS-0042 §3: managed-режим CustoAI удалён вместе с тарифным контуром.
# BYOK — единственный режим; поле credential_mode больше не нужно.
# ADR-CHATBALLS-0042 §3: BYOK — единственный режим; поле credential_mode больше не нужно.
from django.db import migrations
+5 -7
View File
@@ -6,7 +6,7 @@ from pgvector.django import VectorField
from chatballs.tenancy.models import TenantRelationModel
# Один основной агент на канал обработки (ADR-HUB-0019, ADR-CHATBALLS-0023).
# Один основной агент на канал обработки (ADR-CHATBALLS-0023).
DEFAULT_AI_MODEL = "anthropic/claude-sonnet-4.6"
@@ -40,8 +40,6 @@ class AIAgentStatus(models.TextChoices):
# Managed-режим CustoAI удалён вместе с тарифным контуром (ADR-CHATBALLS-0042 §3):
# AI работает только через провайдера организации (AIAgent.provider_integration).
@@ -151,7 +149,7 @@ class KnowledgeAttachment(TenantRelationModel):
# Непредсказуемый идентификатор публичной ссылки скачивания (ADR-CHATBALLS-0023):
# агент может отдать ссылку клиенту в мессенджер, где нет аутентификации Hub.
# агент может отдать ссылку клиенту в мессенджер, где нет аутентификации установки.
public_id = models.UUIDField(default=uuid.uuid4, unique=True, editable=False)
@@ -193,7 +191,7 @@ class KnowledgeAttachment(TenantRelationModel):
# Абсолютная ссылка скачивания: уходит клиентам в мессенджеры, поэтому
# строится от публичного адреса Hub, а не от request.
# строится от публичного адреса установки, а не от request.
from django.urls import reverse
@@ -343,7 +341,7 @@ class AIAgent(TenantRelationModel):
channel = models.OneToOneField("channels.Channel", on_delete=models.CASCADE, related_name="ai_agent")
# BYOK-секрет организации (SPEC-HUB-0027 §9). Раньше жил на Channel, из-за
# BYOK-секрет организации. Раньше жил на Channel, из-за
# чего credential_mode и model были на агенте, а секрет — на канале: одно
@@ -494,7 +492,7 @@ class LlmInvocation(TenantRelationModel):
tenant_relation_fields = ("channel",)
# Учёт по каналу (ADR-HUB-0019).
# Учёт по каналу.
channel = models.ForeignKey("channels.Channel", on_delete=models.SET_NULL, null=True, blank=True, related_name="ai_invocations")
@@ -1,10 +1,8 @@
"""Shared HTTP layer for OpenAI-compatible LLM providers (ADR-HUB-0033 §7,
ADR-CHATBALLS-0034 §3).
"""Shared HTTP layer for OpenAI-compatible LLM providers (ADR-CHATBALLS-0034 §3).
The OpenRouter, generic Custom and CustoAI (Yandex AI Studio) providers all
The OpenRouter and generic Custom providers both
speak the same Chat Completions shape:
@@ -20,7 +18,7 @@ speak the same Chat Completions shape:
This module owns the HTTP transport and response parsing so the three adapters
This module owns the HTTP transport and response parsing so the adapters
do not duplicate it. Adapters stay responsible for their own product semantics
@@ -38,17 +36,11 @@ import urllib.error
import urllib.request
from chatballs.ai.provider.base import (
ChatMessage,
ChatResult,
EmbeddingResult,
ProviderError,
ProviderRejected,
)
from chatballs.i18n import t
from chatballs.integrations.proxy import build_opener
@@ -8,7 +8,7 @@ class OpenRouterProvider(LLMProvider):
OpenAI Chat Completions shape with usage.include=true (returns the actual
USD cost in usage.cost). Delegates HTTP/parsing to the shared openai_http
layer (ADR-HUB-0033 §7, ADR-CHATBALLS-0034 §3); this adapter only carries the
layer (ADR-CHATBALLS-0034 §3); this adapter only carries the
OpenRouter product semantics (cost reporting). Exercised with a real key;
tests use the LocalProvider.
"""
@@ -38,10 +38,10 @@ def call_with_resilience(
sleep: Callable[[float], None] = time.sleep,
backoff: float = 0.5,
):
if breaker is not None:
breaker.before()
attempt = 0
while True:
if breaker is not None:
breaker.before()
try:
result = func()
except ProviderRejected:
@@ -49,10 +49,10 @@ def call_with_resilience(
# предохранитель тут ни при чём — сам провайдер жив и отвечает.
raise
except ProviderError:
if breaker is not None:
breaker.on_failure()
attempt += 1
if attempt > retries:
if breaker is not None:
breaker.on_failure()
raise
sleep(backoff * attempt)
continue
+13 -3
View File
@@ -5,7 +5,7 @@ Resolves an LLM provider and the effective model from the channel agent's
own credentials, the integration is selected explicitly on `AIAgent`, and
managed AI credits are not consumed.
Источник провайдера переехал с канала на агента (SPEC-HUB-0027 §9). Один
Источник провайдера переехал с канала на агента. Один
релиз резолвер падает на `Channel.provider_integration` для записей, не
попавших в data-миграцию; после удаления поля канала fallback уходит.
@@ -14,7 +14,7 @@ the owner's choice is forbidden (ADR-CHATBALLS-0020:45). The integration MUST be
the one the agent points at.
This module also closes the as-built gap where the OpenRouter «Модель по
умолчанию» field was decorative (SPEC-HUB-0005:388, SPEC-CHATBALLS-0024 §4.3, §6):
умолчанию» field was decorative (SPEC-CHATBALLS-0024 §4.3, §6):
for OpenRouter and Custom integrations the configured `default_model` is read
at runtime and overrides `AIAgent.model`.
"""
@@ -114,7 +114,7 @@ def _channel_integration(channel) -> Integration:
agent = getattr(channel, "ai_agent", None)
integration = getattr(agent, "provider_integration", None) if agent else None
if integration is None:
# Переходный fallback на один релиз (SPEC-HUB-0027 §9 шаг 3): записи,
# Переходный fallback на один релиз: записи,
# не попавшие в data-миграцию, продолжают работать через канал.
integration = getattr(channel, "provider_integration", None)
if integration is None or not integration.secret:
@@ -136,6 +136,16 @@ def integration_id(channel) -> int:
return 0
def integration_runtime_identity(channel) -> tuple[int, int]:
"""Идентификатор и версия runtime-настроек выбранного провайдера."""
try:
integration = _channel_integration(channel)
except IntegrationNotConfigured:
return 0, 0
return integration.id, integration.runtime_revision
def _provider_from_integration(
integration: Integration, *, timeout: float | None = None
) -> LLMProvider:
@@ -1,7 +1,7 @@
"""Выбор LLM-провайдера агента (SPEC-HUB-0027 §9, ADR-CHATBALLS-0042 §3).
"""Выбор LLM-провайдера агента (ADR-CHATBALLS-0042 §3).
Managed-режим CustoAI удалён вместе с тарифным контуром: агент работает только
через интеграцию организации (BYOK, ADR-CHATBALLS-0034). Функция ничего не пишет:
Агент работает только через интеграцию организации (BYOK,
ADR-CHATBALLS-0034). Функция ничего не пишет:
возвращает разрешённую интеграцию и модель, вызывающий сервис ставит их на
агента в одной транзакции. Агент без интеграции — валидное состояние черновика;
активация без провайдера запрещена в set_agent_active.
@@ -130,7 +130,7 @@ class ProviderModeTests(TestCase):
class AgentProviderOwnershipTests(TestCase):
"""SPEC-HUB-0027 §9 — провайдер живёт на агенте, канал не изменяется."""
"""Провайдер живёт на агенте, канал не изменяется."""
def setUp(self) -> None:
bootstrap_owner(email="owner@example.com", password="temporary-password")
@@ -0,0 +1,67 @@
from django.test import SimpleTestCase
from chatballs.ai.invocation import _breaker, reset_breakers
from chatballs.ai.provider.base import ProviderError
from chatballs.ai.provider.resilience import (
CircuitBreaker,
CircuitBreakerOpen,
call_with_resilience,
)
class ProviderResilienceTests(SimpleTestCase):
def test_retries_count_as_one_logical_failure(self) -> None:
breaker = CircuitBreaker(failure_threshold=2, reset_timeout=999)
calls = {"n": 0}
def always_fail():
calls["n"] += 1
raise ProviderError("down")
for _ in range(2):
with self.assertRaises(ProviderError):
call_with_resilience(
always_fail,
retries=2,
breaker=breaker,
sleep=lambda _s: None,
)
self.assertEqual(calls["n"], 6)
with self.assertRaises(CircuitBreakerOpen):
call_with_resilience(always_fail, retries=2, breaker=breaker)
self.assertEqual(calls["n"], 6)
def test_circuit_breaker_allows_probe_after_cooldown(self) -> None:
now = [0.0]
breaker = CircuitBreaker(
failure_threshold=1,
reset_timeout=30,
clock=lambda: now[0],
)
def fail():
raise ProviderError("down")
with self.assertRaises(ProviderError):
call_with_resilience(fail, retries=0, breaker=breaker)
now[0] = 29
with self.assertRaises(CircuitBreakerOpen):
call_with_resilience(lambda: "ok", retries=0, breaker=breaker)
now[0] = 30
self.assertEqual(
call_with_resilience(lambda: "ok", retries=0, breaker=breaker),
"ok",
)
def test_runtime_revision_replaces_open_breaker(self) -> None:
reset_breakers()
self.addCleanup(reset_breakers)
first = _breaker((1, 2), revision=1)
for _ in range(first.failure_threshold):
first.on_failure()
with self.assertRaises(CircuitBreakerOpen):
first.before()
second = _breaker((1, 2), revision=2)
self.assertIsNot(second, first)
second.before()
+1 -1
View File
@@ -16,7 +16,7 @@ _MEDIA_ROOT = tempfile.mkdtemp(prefix="hub-test-media-")
def make_channel_with_agent(organization, *, code, name, model="openai/gpt-4o-mini"):
"""Канал обработки + его агент (ADR-HUB-0019/0023). Bootstrap не создаёт
"""Канал обработки + его агент (ADR-CHATBALLS-0023). Bootstrap не создаёт
каналы/агентов — в тестах их собирает этот helper."""
channel = Channel.objects.create(organization=organization, code=code, name=name)
agent = AIAgent.objects.create(
@@ -1,4 +1,4 @@
# SPEC-HUB-0027 §3.2/§12, ADR-HUB-0037 §7 — этап 3.
# Проверка инвариантов политики канала.
#
# Приводит существующие каналы в соответствие P1-P2 и меняет дефолты модели,
# которые сами по себе их нарушали: attribution и checkout были включены по
+1 -1
View File
@@ -1,6 +1,6 @@
from django.db import models
# Канал обработки — якорь AI-контекста (ADR-HUB-0019). Группа видимости и
# Канал обработки — якорь AI-контекста. Группа видимости и
# ссылка на провайдер-интеграцию. Поведение AI (модель, инструкции, знания)
# живёт на агенте канала (ADR-CHATBALLS-0023).
+1 -1
View File
@@ -4,7 +4,7 @@ from chatballs.i18n import t
def run_channel_turn(*, channel, message: str, history: list[dict] | None = None) -> ChatResult:
"""One AI turn for a processing channel (ADR-HUB-0019/0023).
"""One AI turn for a processing channel (ADR-CHATBALLS-0023).
AI-поведение канала целиком определяет его агент. Без активного агента
AI-ответа нет: вызывающий код (ingest/support) обрабатывает ProviderError
+1 -2
View File
@@ -13,8 +13,7 @@ def _with_relations(queryset: QuerySet[Channel]) -> QuerySet[Channel]:
).prefetch_related(
Prefetch("connections", queryset=Integration.objects.order_by("id"))
).annotate(
# Один агрегат на весь список: N+1 запросов на счётчик не допускается
# (SPEC-HUB-0027 §6.1).
# Один агрегат на весь список: N+1 запросов на счётчик не допускается.
open_conversations_count=Count(
"conversations",
filter=Q(conversations__lifecycle=LifecycleState.OPEN),
+2 -2
View File
@@ -1,4 +1,4 @@
"""Операции над каналом обработки (SPEC-HUB-0027 §6, §7)."""
"""Операции над каналом обработки."""
from __future__ import annotations
@@ -42,7 +42,7 @@ class ChannelHasReferences(Exception):
class ConnectionAlreadyBound(Exception):
"""Подключение принадлежит ровно одному каналу (ADR-HUB-0019)."""
"""Подключение принадлежит ровно одному каналу."""
def __init__(self, *, integration: Integration) -> None:
self.integration = integration
+4 -2
View File
@@ -9,5 +9,7 @@ class ConversationsConfig(AppConfig):
def ready(self) -> None:
# Свежесть диалога поддерживает сигнал: сообщения создаются в семи местах.
from chatballs.conversations import signals # noqa: F401
from chatballs.conversations import event_handlers # noqa: F401 (register outbox handlers)
from chatballs.conversations import (
event_handlers, # noqa: F401 (register outbox handlers)
signals, # noqa: F401
)
@@ -7,6 +7,8 @@
from __future__ import annotations
import re
from django.db.models import Count, Q
from django.utils import timezone
from rest_framework.request import Request
@@ -38,6 +40,26 @@ def _label_payload(label: ConversationLabel) -> dict[str, object]:
return {"id": label.id, "name": label.name, "color": label.color}
# Переменные шаблонов ответов: подставляет их интерфейс оператора при вставке
# шаблона (internal-ui, conversations/templateVariables.ts — тот же список).
TEMPLATE_VARIABLES = frozenset({"client_name", "operator_name", "company"})
_TEMPLATE_TOKEN = re.compile(r"\{\{\s*(\w+)\s*\}\}")
def _template_text_error(text: str) -> Response | None:
if not text:
return Response({"detail": t("conversations.template_text_required")}, status=400)
unknown = sorted(
{match.group(0) for match in _TEMPLATE_TOKEN.finditer(text) if match.group(1) not in TEMPLATE_VARIABLES}
)
if unknown:
return Response(
{"detail": t("conversations.template_unknown_variables", names=", ".join(unknown))},
status=400,
)
return None
def _template_payload(template: ReplyTemplate) -> dict[str, object]:
return {
"id": template.id,
@@ -443,8 +465,8 @@ class ReplyTemplateListView(APIView):
text = str(request.data.get("text", "")).strip()
if not title or len(title) > 120:
return Response({"detail": t("conversations.template_name_length")}, status=400)
if not text:
return Response({"detail": t("conversations.template_text_required")}, status=400)
if error := _template_text_error(text):
return error
if ReplyTemplate.objects.filter(
organization_id=request.tenant_context.organization_id, title__iexact=title
).exists():
@@ -475,11 +497,15 @@ class ReplyTemplateDetailView(APIView):
title = str(request.data.get("title", "")).strip()
if not title or len(title) > 120:
return Response({"detail": t("conversations.template_name_length")}, status=400)
if ReplyTemplate.objects.filter(
organization_id=template.organization_id, title__iexact=title
).exclude(id=template.id).exists():
return Response({"detail": t("conversations.template_name_taken")}, status=409)
template.title = title
if "text" in request.data:
text = str(request.data.get("text", "")).strip()
if not text:
return Response({"detail": t("conversations.template_text_required")}, status=400)
if error := _template_text_error(text):
return error
template.text = text
template.save()
return Response({"template": _template_payload(template)})
@@ -7,7 +7,7 @@ from django.utils import timezone
from chatballs.i18n import t
from chatballs.tenancy.models import TenantRelationModel
# Минимальный домен диалогов (ADR-HUB-0001/0002/0003/0006). Состояние диалога
# Минимальный домен диалогов (ADR-CHATBALLS-0002/0003/0006). Состояние диалога
# разделено на независимые оси; перехват оператором — атомарный.
@@ -157,15 +157,28 @@ def _history_item(conversation: Conversation) -> dict[str, object]:
}
def _contact_username(conversation: Conversation) -> str:
# Username живёт на identity подключения (у контакта их может быть несколько).
# Только в detail-режиме — в списках это лишний запрос на каждый диалог.
def _connection_identity(conversation: Conversation) -> ConnectionIdentity | None:
# Username и подпись гостя живут на identity подключения (у контакта их
# может быть несколько). Только в detail-режиме — в списках это лишний
# запрос на каждый диалог.
if not conversation.connection_id:
return ""
identity = ConnectionIdentity.objects.filter(
return None
return ConnectionIdentity.objects.filter(
connection_id=conversation.connection_id, contact_id=conversation.contact_id
).first()
return identity.username if identity else ""
def _contact_is_guest(contact, identity: ConnectionIdentity | None) -> bool:
# Гость виджета получает имя «Гость · <код сессии>» на языке организации;
# та же подпись записана в display_name его identity. Пока имя не сменили,
# настоящего имени у контакта нет — подставлять его в ответ нельзя.
if not contact.name:
return True
return bool(
identity
and identity.display_name == contact.name
and identity.external_user_id[:6] in contact.name
)
def _contact_email(conversation: Conversation) -> str:
@@ -206,6 +219,7 @@ def conversation_payload(
# pending_counts_for); поштучный расчёт остаётся для одиночных ответов.
last = None if detailed else (last_message or _last_message(conversation))
channel = conversation.channel
identity = _connection_identity(conversation) if detailed and conversation.contact_id else None
payload = {
"id": conversation.id,
"channel": {
@@ -236,7 +250,9 @@ def conversation_payload(
"company": conversation.contact.company,
"city": conversation.contact.city,
"email": _contact_email(conversation),
"username": _contact_username(conversation) if detailed else "",
"username": identity.username if identity else "",
# Для переменной {{client_name}} шаблонов ответов.
**({"isGuest": _contact_is_guest(conversation.contact, identity)} if detailed else {}),
}
if conversation.contact_id
else None
@@ -1,10 +1,11 @@
import json
from django.test import TestCase
from django.test import SimpleTestCase, TestCase
from django.utils import timezone
from chatballs.channels.models import Channel
from chatballs.conversations.models import (
ConnectionIdentity,
Contact,
ControlMode,
Conversation,
@@ -13,6 +14,7 @@ from chatballs.conversations.models import (
LifecycleState,
ReplyTemplate,
)
from chatballs.conversations.serializers import _contact_is_guest
from chatballs.identity.group_models import EmployeeGroup, EmployeeGroupMember
from chatballs.identity.models import (
EmployeeRole,
@@ -339,6 +341,65 @@ class ReplyTemplateTests(ChatExtrasTestCase):
self.assertEqual(deleted.status_code, 204)
self.assertFalse(ReplyTemplate.objects.exists())
def test_rename_to_taken_title_conflicts(self) -> None:
ReplyTemplate.objects.create(
organization_id=self.organization.id, title="Приветствие", text="Здравствуйте!"
)
other = ReplyTemplate.objects.create(
organization_id=self.organization.id, title="Прощание", text="До свидания!"
)
taken = self.admin_client.patch(
f"/api/v1/conversations/templates/{other.id}/",
data=json.dumps({"title": "приветствие"}),
content_type="application/json",
)
self.assertEqual(taken.status_code, 409)
other.refresh_from_db()
self.assertEqual(other.title, "Прощание")
# Смена регистра собственного названия — не конфликт.
recased = self.admin_client.patch(
f"/api/v1/conversations/templates/{other.id}/",
data=json.dumps({"title": "ПРОЩАНИЕ"}),
content_type="application/json",
)
self.assertEqual(recased.status_code, 200)
self.assertEqual(recased.json()["template"]["title"], "ПРОЩАНИЕ")
def test_template_variables_are_checked(self) -> None:
created = self.admin_client.post(
"/api/v1/conversations/templates/",
data=json.dumps({"title": "Приветствие", "text": "Здравствуйте, {{client_name}}! Я {{ operator_name }}, {дата}."}),
content_type="application/json",
)
self.assertEqual(created.status_code, 201)
typo = self.admin_client.post(
"/api/v1/conversations/templates/",
data=json.dumps({"title": "Опечатка", "text": "Здравствуйте, {{clent_name}}!"}),
content_type="application/json",
)
self.assertEqual(typo.status_code, 400)
self.assertIn("{{clent_name}}", typo.json()["detail"])
patched = self.admin_client.patch(
f"/api/v1/conversations/templates/{created.json()['template']['id']}/",
data=json.dumps({"text": "{{order_id}}"}),
content_type="application/json",
)
self.assertEqual(patched.status_code, 400)
class ContactGuestTests(SimpleTestCase):
def test_widget_guest_label_is_not_a_name(self) -> None:
identity = ConnectionIdentity(external_user_id="3ffa7b0c9d", display_name="Гость · 3ffa7b")
self.assertTrue(_contact_is_guest(Contact(name="Гость · 3ffa7b"), identity))
self.assertTrue(_contact_is_guest(Contact(name=""), None))
# Гость представился — имя уже настоящее.
self.assertFalse(_contact_is_guest(Contact(name="Дмитрий Орлов"), identity))
self.assertFalse(_contact_is_guest(Contact(name="Иван"), None))
class OnboardingTests(TestCase):
def setUp(self) -> None:
@@ -19,7 +19,6 @@ from chatballs.conversations.transports.errors import PollFailed
from chatballs.identity.bootstrap import bootstrap_owner
from chatballs.identity.models import Organization
from chatballs.integrations.models import Integration, IntegrationKind, IntegrationProvider
from chatballs.testing import ai_answer, run_pending_ai_turns
EMAIL_CONFIG = {
@@ -22,7 +22,8 @@ from chatballs.integrations.models import (
IntegrationKind,
IntegrationProvider,
)
from chatballs.testing import TenantAPIClient as APIClient, run_pending_ai_turns
from chatballs.testing import TenantAPIClient as APIClient
from chatballs.testing import run_pending_ai_turns
def _connection(channel: Channel) -> Integration:
@@ -267,10 +267,10 @@ class VkOutboundTests(SimpleTestCase):
with mock.patch.object(vk_send.vk_api, "call", api):
with mock.patch.object(vk_send, "customer_language", return_value="ru"):
sent = vk_send.send_call_invite(
_integration(), chat_id="77", user_id="77", text="Звонок", url="https://hub.test/calls/abc"
_integration(), chat_id="77", user_id="77", text="Звонок", url="https://chatballs.test/calls/abc"
)
self.assertTrue(sent)
self.assertIn("https://hub.test/calls/abc", api.params("messages.send")["keyboard"])
self.assertIn("https://chatballs.test/calls/abc", api.params("messages.send")["keyboard"])
def test_rejected_send_is_reported_as_failure(self) -> None:
api = _Api(**{"messages.send": VkRejected("ВКонтакте отклонил запрос (7): access denied")})
@@ -9,6 +9,7 @@ from django.test import TestCase
from chatballs.ai.models import AIAgent, AIAgentStatus
from chatballs.ai.provider.base import ProviderError
from chatballs.channels.models import Channel
from chatballs.conversations import ai_turn
from chatballs.conversations.ingest import ingest_inbound
from chatballs.conversations.models import ControlMode, MessageAuthor, MessageKind, TranscriptStatus
from chatballs.conversations.transports.base import InboundMessage
@@ -16,8 +17,8 @@ from chatballs.identity.bootstrap import bootstrap_owner
from chatballs.identity.models import Organization
from chatballs.integrations.models import Integration, IntegrationKind, IntegrationProvider
from chatballs.tenancy.database import tenant_atomic
from chatballs.conversations import ai_turn
from chatballs.testing import TenantAPIClient as APIClient, ai_answer, run_pending_ai_turns
from chatballs.testing import TenantAPIClient as APIClient
from chatballs.testing import ai_answer, run_pending_ai_turns
class VoiceAiReplyTests(TestCase):
@@ -27,8 +27,8 @@ from chatballs.identity.models import (
)
from chatballs.integrations.models import Integration, IntegrationKind, IntegrationProvider
from chatballs.notifications.models import Notification, NotificationAudience, NotificationType
from chatballs.testing import ai_answer, ai_failure, run_pending_ai_turns
from chatballs.testing import TenantAPIClient as APIClient
from chatballs.testing import ai_answer, ai_failure, run_pending_ai_turns
def _messenger_connection(channel):
@@ -119,7 +119,7 @@ def multipart_body(
"""Ручной multipart/form-data (в репо urllib, без requests)."""
import uuid
boundary = "----hub" + uuid.uuid4().hex
boundary = "----chatballs" + uuid.uuid4().hex
crlf = "\r\n"
parts: list[bytes] = []
for name, value in fields.items():
@@ -110,7 +110,7 @@ class Command(BaseCommand):
role = str(options["role"])
does_events = role in (ROLE_ALL, ROLE_EVENTS)
does_polling = role in (ROLE_ALL, ROLE_POLLER)
self.stdout.write(f"Hub worker started (role={role})")
self.stdout.write(f"Chatballs worker started (role={role})")
last_poll = 0.0
last_maintenance = 0.0
last_call_sweep = 0.0
@@ -12,7 +12,6 @@ from chatballs.identity.models import OrganizationMembership
from chatballs.tenancy.context import TenantActorKind, TenantContext
from chatballs.tenancy.lookup import load_organization
# Где живёт outbox. Захват идёт по всем организациям сразу, поэтому читает и
# отмечает события роль platform, а не app (chatballs.tenancy.routing).
OUTBOX_DB = "platform"
@@ -76,6 +76,7 @@ MESSAGES: dict[str, object] = {
"conversations.template_name_taken": "A template with this name already exists",
"conversations.template_not_found": "Template not found",
"conversations.template_text_required": "The template text is required",
"conversations.template_unknown_variables": "Unknown variables: {names}. Use the “Insert a variable” button to add variables.",
"conversations.unknown_label": "Unknown label",
"conversations.unknown_priority": "Unknown priority",
"conversations.voice_off_entry_point": "Voice messages are switched off for this entry point",
@@ -494,6 +495,7 @@ MESSAGES: dict[str, object] = {
"settings.email_hosts_required": "Email address, IMAP host and SMTP host are required",
"settings.email_not_a_bot": "Email cannot be a notifications bot",
"settings.inactive_channel": "Inactive channel cannot accept connections",
"settings.integration_in_use": "The integration is used by an agent or channel. Select another integration for them first.",
"settings.integration_other_organization": "Integration belongs to another organization",
"settings.mailbox_password_required": "Mailbox password is required",
"settings.name_required": "Name required",
@@ -80,6 +80,7 @@ MESSAGES: dict[str, object] = {
"conversations.template_name_taken": "Шаблон с таким названием уже есть",
"conversations.template_not_found": "Шаблон не найден",
"conversations.template_text_required": "Текст шаблона обязателен",
"conversations.template_unknown_variables": "Неизвестные переменные: {names}. Вставляйте переменные кнопкой «Вставить переменную».",
"conversations.unknown_label": "Неизвестная метка",
"conversations.unknown_priority": "Неизвестный приоритет",
"conversations.voice_off_entry_point": "Голосовые отключены для этой точки входа",
@@ -498,6 +499,7 @@ MESSAGES: dict[str, object] = {
"settings.email_hosts_required": "Укажите адрес, IMAP- и SMTP-сервер",
"settings.email_not_a_bot": "Почта не может быть ботом уведомлений",
"settings.inactive_channel": "Неактивный канал не принимает подключения",
"settings.integration_in_use": "Интеграция используется агентом или каналом. Сначала выберите для них другую интеграцию.",
"settings.integration_other_organization": "Интеграция принадлежит другой организации",
"settings.mailbox_password_required": "Укажите пароль ящика",
"settings.name_required": "Укажите название",
@@ -81,7 +81,7 @@ class InvitationRegisterView(APIView):
class InvitationAcceptView(APIView):
"""Accept an organization invitation: OWNER (SPEC-HUB-0021 §8.2) or employee.
"""Accept an organization invitation: OWNER or employee.
Authenticated endpoint: the caller must already have a HumanUser account
(created through sign-up / password setup). The token is read from the body;
+1 -1
View File
@@ -41,7 +41,7 @@ def bootstrap_owner(*, email: str, password: str, full_name: str = "") -> Bootst
support_group, _ = EmployeeGroup.objects.get_or_create(
organization=organization, name="Поддержка"
)
# Каналы обработки и их агенты (ADR-HUB-0019) создаются через API каналов.
# Каналы обработки и их агенты создаются через API каналов.
owner, created_owner = HumanUser.objects.get_or_create(
email=HumanUser.objects.normalize_email(email),
@@ -189,7 +189,7 @@
"replyTemplates": [
{
"title": "Greeting",
"text": "Hello! My name is {name}, I am a consultant at Nord Atelier. Could you tell me the track width and the window drop — I will work out a quote."
"text": "Hello! My name is {{operator_name}}, I am a consultant at Nord Atelier. Could you tell me the track width and the window drop — I will work out a quote."
},
{
"title": "Lead times",
@@ -189,7 +189,7 @@
"replyTemplates": [
{
"title": "Приветствие",
"text": "Здравствуйте! Меня зовут {имя}, я консультант ателье «Норд». Подскажите, пожалуйста, ширину карниза и высоту окна — сделаю расчёт."
"text": "Здравствуйте! Меня зовут {{operator_name}}, я консультант ателье «Норд». Подскажите, пожалуйста, ширину карниза и высоту окна — сделаю расчёт."
},
{
"title": "Сроки пошива",
@@ -30,7 +30,7 @@ from chatballs.tenancy.ingress import invitation_route
# Приглашение существующего пользователя в организацию: письмо отправляет
# воркер по этому событию (identity.event_handlers).
MEMBERSHIP_INVITATION_REQUESTED = "identity.membership_invitation_requested"
# Приглашение владельца из платформенного провижининга (SPEC-HUB-0021 §8.2):
# Приглашение владельца из платформенного провижининга:
# учётной записи может ещё не быть, тогда человек создаёт её по ссылке.
OWNER_INVITATION_REQUESTED = "organization.owner_invitation_requested"
MEMBERSHIP_INVITATION_TTL = timedelta(days=7)
@@ -240,7 +240,7 @@ def _invitation_for_token(token: str, *, accepted: bool) -> OrganizationInvitati
@transaction.atomic
def accept_invitation(*, token: str, user: HumanUser) -> AcceptedInvitation:
"""Принять приглашение: владельца (SPEC-HUB-0021 §8.2) или сотрудника.
"""Принять приглашение: владельца или сотрудника.
Членство собирается из полей приглашения; для владельца организация ещё
и активируется. Идемпотентно: повторное принятие того же токена не создаёт
@@ -337,7 +337,7 @@ def _already_accepted_for(
token: str, user: HumanUser
) -> AcceptedInvitation | None:
"""Idempotent re-accept: if this token was already accepted by the same user,
return the existing result instead of raising (SPEC-HUB-0021 §11/§15)."""
return the existing result instead of raising."""
invitation = _invitation_for_token(token, accepted=True)
if invitation is None:
return None
@@ -1,4 +1,4 @@
# ADR-HUB-0027 / SPEC-HUB-0018 фаза M1-M2 (этап 1): additive schema + backfill.
# Роли, должности и отделы сотрудников: additive schema + backfill.
# Роли OWNER/ADMIN/EMPLOYEE, обязательная должность (пока nullable), основной отдел
# и размещение владельца на уровне компании. Доступ существующих сотрудников не
# расширяется: OPERATOR → EMPLOYEE, права сохраняются compatibility-адаптером.
@@ -8,7 +8,7 @@ from django.db import migrations, models
def operator_to_employee(apps, schema_editor):
EmployeeProfile = apps.get_model("identity", "EmployeeProfile")
# OPERATOR больше не является системной ролью (ADR-HUB-0027).
# OPERATOR больше не является системной ролью.
EmployeeProfile.objects.filter(role="OPERATOR").update(role="EMPLOYEE")
# Владелец всегда на уровне компании: снимаем основной отдел.
EmployeeProfile.objects.filter(role="OWNER").exclude(primary_department__isnull=True).update(
@@ -1,4 +1,4 @@
# ADR-HUB-0027 / SPEC-HUB-0018 фаза M2 (этап 1): после явного заполнения должностей
# После явного заполнения должностей
# включается обязательность. Оставшиеся незаполненные значения нормализуются в "";
# непустая должность гарантируется application contract (SPEC-CHATBALLS-0016 §5).
from django.db import migrations, models
@@ -1,4 +1,4 @@
# Generated for C06 tenant provisioning (SPEC-HUB-0021 §6/§8).
# Generated for tenant provisioning.
# Adds Organization.status and backfills existing organizations to ACTIVE.
from django.db import migrations, models
@@ -1,4 +1,4 @@
# SPEC-HUB-0027 §5.1/§5.3, ADR-HUB-0037 §9 — этап 1.
# Права на управление каналами.
#
# Вводит `channels.view` / `channels.manage` и выдаёт их существующим профилям
# доступа по текущим `ai.view` / `ai.manage`, чтобы никто не потерял доступ в
+1 -1
View File
@@ -102,7 +102,7 @@ class HumanUser(AbstractUser):
return self.email
class OrganizationStatus(models.TextChoices):
# SPEC-HUB-0021 §6/§8: PENDING_OWNER до принятия OWNER invitation, ACTIVE после.
# PENDING_OWNER до принятия OWNER invitation, ACTIVE после.
ACTIVE = "ACTIVE", "Active"
PENDING_OWNER = "PENDING_OWNER", "Pending owner"
+1 -1
View File
@@ -7,7 +7,7 @@
Запись идёт по основному соединению процесса. Роль app вправе вставить
организацию только пока их нет (политика tenancy/0032); дальше создавать
организации может только роль platform (SPEC-HUB-0021 §10). Так пароль
организации может только роль platform. Так пароль
platform-роли не нужен процессу backend-app.
"""
+2 -2
View File
@@ -770,7 +770,7 @@ class ThrottlingTests(TestCase):
class EmployeeModelInvariantTests(TestCase):
"""ADR-HUB-0027 / SPEC-CHATBALLS-0016 §5,§7 — инварианты модели сотрудника после
"""SPEC-CHATBALLS-0016 §5,§7 — инварианты модели сотрудника после
миграции этапа 1: роли OWNER/ADMIN/EMPLOYEE, обязательная должность,
размещение владельца на уровне компании и ровно один владелец на организацию."""
@@ -823,7 +823,7 @@ class EmployeeModelInvariantTests(TestCase):
class EmployeeGovernanceTests(TestCase):
"""ADR-HUB-0027 этап 2 / SPEC-CHATBALLS-0016 §8,§12: административная иерархия
"""SPEC-CHATBALLS-0016 §8,§12: административная иерархия
OWNER/ADMIN/EMPLOYEE, target-aware управление и передача владения."""
def setUp(self) -> None:
@@ -0,0 +1,78 @@
from django.core.exceptions import ValidationError
from django.utils import timezone
from chatballs.i18n import t
from chatballs.integrations import checks
from chatballs.integrations.models import Integration, IntegrationProvider, IntegrationStatus
from chatballs.integrations.runtime import advance_revision_after_successful_check
from chatballs.tenancy.context import TenantContext
_CHECKS = {
IntegrationProvider.OPENROUTER: checks.check_openrouter,
IntegrationProvider.CUSTOM: checks.check_custom,
IntegrationProvider.DEMO: checks.check_demo,
IntegrationProvider.MAX: checks.check_max,
IntegrationProvider.TELEGRAM: checks.check_telegram,
IntegrationProvider.VK: checks.check_vk,
}
def _check_web(context: TenantContext, integration: Integration) -> tuple[bool, str, dict]:
"""Проверить конфигурацию собственного Web-виджета без внешнего API."""
if integration.channel_id is None:
return False, t("integrations.check_web_not_bound"), {}
from chatballs.webchat.widgets import ensure_widget
try:
widget = ensure_widget(integration)
except ValidationError as error:
return False, "; ".join(error.messages), {}
if widget is None:
return False, t("integrations.check_web_no_config"), {}
if not widget.allowed_origins:
return False, t("integrations.check_web_no_origins"), {}
return True, t("integrations.check_web_active", channel=integration.channel.name), {}
def test_integration(*, context: TenantContext, integration: Integration) -> Integration:
if integration.organization_id != context.organization_id:
raise ValidationError({"integration": t("settings.integration_other_organization")})
if integration.provider == IntegrationProvider.WEB:
ok, detail, meta = _check_web(context, integration)
elif integration.provider == IntegrationProvider.EMAIL:
ok, detail, meta = checks.check_email(
secret=integration.secret,
config=integration.config,
)
else:
check = _CHECKS.get(integration.provider)
if check is None:
ok, detail, meta = False, t("integrations.check_unsupported"), {}
else:
ok, detail, meta = check(
secret=integration.secret,
base_url=str(integration.config.get("base_url", "")),
proxy_url=str(integration.config.get("proxy_url", "")),
)
integration.status = IntegrationStatus.OK if ok else IntegrationStatus.ERROR
integration.last_error = "" if ok else detail
integration.last_checked_at = timezone.now()
update_fields = ["status", "last_error", "last_checked_at", "updated_at"]
if ok and advance_revision_after_successful_check(integration):
# API и event-workers — разные процессы; ревизия инвалидирует их breaker.
update_fields.append("runtime_revision")
if ok and meta:
config = {**integration.config}
for key in ("bot_id", "bot_username", "bot_name"):
if meta.get(key):
config[key] = meta[key]
if config != integration.config:
integration.config = config
update_fields.append("config")
integration.save(update_fields=update_fields)
if integration.provider == IntegrationProvider.WEB:
from chatballs.webchat.widgets import sync_widget_check_status
sync_widget_check_status(integration, ok=ok)
return integration
@@ -0,0 +1,19 @@
from django.core.exceptions import ValidationError
from django.db.models.deletion import ProtectedError
from chatballs.i18n import t
from chatballs.integrations.models import Integration
from chatballs.tenancy.context import TenantContext
class IntegrationInUse(Exception):
"""Интеграция связана с агентом или каналом и не может быть удалена."""
def delete_integration(*, context: TenantContext, integration: Integration) -> None:
if integration.organization_id != context.organization_id:
raise ValidationError({"integration": t("settings.integration_other_organization")})
try:
integration.delete()
except ProtectedError as error:
raise IntegrationInUse(t("settings.integration_in_use")) from error
@@ -0,0 +1,15 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("integrations", "0009_integration_vk"),
]
operations = [
migrations.AddField(
model_name="integration",
name="runtime_revision",
field=models.PositiveBigIntegerField(default=1),
),
]
@@ -3,7 +3,7 @@ from django.db import models
from chatballs.identity.crypto import EncryptedCharField
# Интеграции: провайдеры (LLM) и подключения (боты/виджеты). ADR-CHATBALLS-0020.
# Привязка подключения к каналу обработки появляется в M1 (ADR-HUB-0019).
# Привязка подключения к каналу обработки появляется в M1.
class IntegrationKind(models.TextChoices):
@@ -63,12 +63,15 @@ class Integration(models.Model):
voice_messages_enabled = models.BooleanField(default=True)
audio_calls_enabled = models.BooleanField(default=True)
video_calls_enabled = models.BooleanField(default=True)
# Подключение (бот/виджет) привязано к каналу обработки (ADR-HUB-0019, M2).
# Подключение (бот/виджет) привязано к каналу обработки.
channel = models.ForeignKey("channels.Channel", on_delete=models.SET_NULL, null=True, blank=True, related_name="connections")
# Курсор Long Polling (marker MAX / offset Telegram).
poll_marker = models.CharField(max_length=64, blank=True)
last_checked_at = models.DateTimeField(null=True, blank=True)
last_error = models.TextField(blank=True)
# Версия runtime-настроек LLM. Event-workers держат circuit breaker в своей
# памяти и заменяют его после исправления конфигурации провайдера.
runtime_revision = models.PositiveBigIntegerField(default=1)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
@@ -0,0 +1,20 @@
from chatballs.integrations.models import Integration, IntegrationKind
def advance_revision_after_configuration_change(
integration: Integration,
*,
previous_config: dict,
previous_secret: str,
) -> None:
if integration.kind == IntegrationKind.LLM_PROVIDER and (
integration.config != previous_config or integration.secret != previous_secret
):
integration.runtime_revision += 1
def advance_revision_after_successful_check(integration: Integration) -> bool:
if integration.kind != IntegrationKind.LLM_PROVIDER:
return False
integration.runtime_revision += 1
return True
+15 -80
View File
@@ -2,11 +2,10 @@ from dataclasses import dataclass, field
from django.core.exceptions import ValidationError
from django.db import transaction
from django.utils import timezone
from chatballs.i18n import t
from chatballs.identity.models import Organization
from chatballs.integrations import checks
from chatballs.integrations.checking import test_integration as test_integration
from chatballs.integrations.models import (
PROVIDER_KIND,
Integration,
@@ -19,6 +18,9 @@ from chatballs.integrations.outbound import (
OutboundUrlRejected,
clean_config_url,
)
from chatballs.integrations.runtime import (
advance_revision_after_configuration_change,
)
from chatballs.tenancy.context import TenantContext
@@ -28,7 +30,7 @@ class IntegrationInput:
name: str
secret: str | None = None # None = не менять при update
config: dict = field(default_factory=dict)
channel_id: int | None = None # канал обработки для подключения (ADR-HUB-0019)
channel_id: int | None = None # канал обработки для подключения
is_active: bool | None = None
@@ -118,7 +120,7 @@ def _normalized_config(provider: str, config: dict) -> dict:
except OutboundUrlRejected as error:
raise ValidationError({"config": t("settings.proxy_url_rejected", error=error)}) from error
# LLM-провайдеры (OpenRouter, Custom) хранят модель по умолчанию свободным текстом.
# Для OpenRouter поле исторически декоративно (SPEC-HUB-0005:388); для Custom оно
# Для OpenRouter поле исторически декоративно; для Custom оно
# читается в рантайме (ADR-CHATBALLS-0034 §4). Версионирование модели — дорожка ADR-0034.
if provider in (IntegrationProvider.OPENROUTER, IntegrationProvider.CUSTOM, IntegrationProvider.DEMO):
default_model = str(config.get("defaultModel", config.get("default_model", ""))).strip()
@@ -195,15 +197,17 @@ def create_integration(*, context: TenantContext, data: IntegrationInput) -> Int
_publish_web_widget(context=context, integration=integration)
return integration
@transaction.atomic
def update_integration(
*, context: TenantContext, integration: Integration, data: IntegrationInput
) -> Integration:
if integration.organization_id != context.organization_id:
raise ValidationError({"integration": t("settings.integration_other_organization")})
previous_config = integration.config
previous_secret = integration.secret
normalized_config = _normalized_config(integration.provider, data.config)
integration.name = data.name.strip() or integration.name
integration.config = _normalized_config(integration.provider, data.config)
integration.config = normalized_config
integration.channel = _resolve_channel(
integration.organization,
data.channel_id,
@@ -214,6 +218,11 @@ def update_integration(
# Пустой/отсутствующий секрет при обновлении не затирает существующий.
if data.secret:
integration.secret = data.secret.strip()
advance_revision_after_configuration_change(
integration,
previous_config=previous_config,
previous_secret=previous_secret,
)
integration.status = IntegrationStatus.UNCHECKED
integration.last_checked_at = None
integration.last_error = ""
@@ -222,77 +231,3 @@ def update_integration(
if integration.provider == IntegrationProvider.WEB:
_publish_web_widget(context=context, integration=integration)
return integration
def delete_integration(*, context: TenantContext, integration: Integration) -> None:
if integration.organization_id != context.organization_id:
raise ValidationError({"integration": t("settings.integration_other_organization")})
integration.delete()
_CHECKS = {
IntegrationProvider.OPENROUTER: checks.check_openrouter,
IntegrationProvider.CUSTOM: checks.check_custom,
IntegrationProvider.DEMO: checks.check_demo,
IntegrationProvider.MAX: checks.check_max,
IntegrationProvider.TELEGRAM: checks.check_telegram,
IntegrationProvider.VK: checks.check_vk,
}
def _check_web(context: TenantContext, integration: Integration) -> tuple[bool, str, dict]:
"""Web-виджет обслуживается нашим же backend'ом — внешнего API нет.
Проверяем конфигурацию конкретного widget entry point."""
if integration.channel_id is None:
return False, t("integrations.check_web_not_bound"), {}
from chatballs.webchat.widgets import ensure_widget
try:
widget = ensure_widget(integration)
except ValidationError as error:
return False, "; ".join(error.messages), {}
if widget is None:
return False, t("integrations.check_web_no_config"), {}
# Пустой allowed_origins в проде запрещает вообще все домены (webchat.services.
# origin_allowed), и на сайте виджет молча показывает «Чат временно недоступен».
# Проверка обязана падать здесь, а не оставлять зелёный статус при мёртвом чате.
if not widget.allowed_origins:
return False, t("integrations.check_web_no_origins"), {}
return True, t("integrations.check_web_active", channel=integration.channel.name), {}
def test_integration(*, context: TenantContext, integration: Integration) -> Integration:
if integration.organization_id != context.organization_id:
raise ValidationError({"integration": t("settings.integration_other_organization")})
if integration.provider == IntegrationProvider.WEB:
ok, detail, meta = _check_web(context, integration)
elif integration.provider == IntegrationProvider.EMAIL:
# Email: сигнатура шире общей (нужен весь config), диспетчеризуется отдельно.
ok, detail, meta = checks.check_email(secret=integration.secret, config=integration.config)
else:
check = _CHECKS.get(integration.provider)
if check is None:
ok, detail, meta = False, t("integrations.check_unsupported"), {}
else:
ok, detail, meta = check(secret=integration.secret, base_url=str(integration.config.get("base_url", "")), proxy_url=str(integration.config.get("proxy_url", "")))
integration.status = IntegrationStatus.OK if ok else IntegrationStatus.ERROR
# Диагностика сохраняется на языке того, кто нажал «Проверить»: она живёт до
# следующей проверки, и хранить её кодом, как историю диалога, нечего.
integration.last_error = "" if ok else detail
integration.last_checked_at = timezone.now()
update_fields = ["status", "last_error", "last_checked_at", "updated_at"]
# Идентичность бота (id/username/имя) — из ответа API, авторитетный источник.
if ok and meta:
config = {**integration.config}
for key in ("bot_id", "bot_username", "bot_name"):
if meta.get(key):
config[key] = meta[key]
if config != integration.config:
integration.config = config
update_fields.append("config")
integration.save(update_fields=update_fields)
if integration.provider == IntegrationProvider.WEB:
from chatballs.webchat.widgets import sync_widget_check_status
sync_widget_check_status(integration, ok=ok)
return integration
@@ -0,0 +1,119 @@
from unittest import mock
from django.test import TestCase
from chatballs.ai.models import AIAgent
from chatballs.channels.models import Channel
from chatballs.identity.bootstrap import bootstrap_owner
from chatballs.identity.models import AuditEvent, Organization
from chatballs.integrations import checking
from chatballs.integrations.models import Integration, IntegrationProvider
from chatballs.integrations.services import (
IntegrationInput,
create_integration,
test_integration,
update_integration,
)
from chatballs.testing import system_tenant_context
def custom_input(*, name: str = "Мой провайдер", secret: str | None = "sk-custom", base_url: str = "https://api.example/v1") -> IntegrationInput:
return IntegrationInput(
provider=IntegrationProvider.CUSTOM,
name=name,
secret=secret,
config={"baseUrl": base_url, "defaultModel": "model"},
)
class ProviderRuntimeRevisionTests(TestCase):
def setUp(self) -> None:
bootstrap_owner(email="owner@example.com", password="temporary-password")
self.organization = Organization.objects.get(slug="demo")
self.context = system_tenant_context(self.organization)
def test_runtime_change_advances_revision_but_rename_does_not(self) -> None:
integration = create_integration(context=self.context, data=custom_input(secret="sk-old"))
initial_revision = integration.runtime_revision
renamed = update_integration(
context=self.context,
integration=integration,
data=custom_input(
name="Новое имя",
secret=None,
),
)
self.assertEqual(renamed.runtime_revision, initial_revision)
updated = update_integration(
context=self.context,
integration=renamed,
data=custom_input(name=renamed.name, secret="sk-new", base_url="https://new.example/v1"),
)
self.assertEqual(updated.runtime_revision, initial_revision + 1)
def test_successful_check_advances_runtime_revision(self) -> None:
integration = create_integration(context=self.context, data=custom_input())
initial_revision = integration.runtime_revision
with mock.patch.dict(
checking._CHECKS,
{IntegrationProvider.CUSTOM: lambda **_kwargs: (True, "ok", {})},
):
checked = test_integration(context=self.context, integration=integration)
self.assertEqual(checked.runtime_revision, initial_revision + 1)
class IntegrationDeletionTests(TestCase):
def setUp(self) -> None:
bootstrap_owner(email="owner@example.com", password="temporary-password")
self.organization = Organization.objects.get(slug="demo")
self.context = system_tenant_context(self.organization)
self.client.login(username="owner@example.com", password="temporary-password")
def _url(self, integration_id: int) -> str:
return f"/api/v1/organizations/{self.organization.public_id}/integrations/{integration_id}/"
def test_used_provider_returns_conflict_and_is_not_audited_as_deleted(self) -> None:
integration = create_integration(context=self.context, data=custom_input())
channel = Channel.objects.create(
organization=self.organization,
code="protected-provider",
name="Канал",
)
AIAgent.objects.create(
channel=channel,
name="Агент",
provider_integration=integration,
)
response = self.client.delete(self._url(integration.id))
self.assertEqual(response.status_code, 409)
self.assertIn("используется", response.json()["detail"])
self.assertTrue(Integration.objects.filter(id=integration.id).exists())
self.assertFalse(
AuditEvent.objects.filter(
action="integrations.integration_deleted",
object_id=str(integration.id),
).exists()
)
def test_unused_provider_is_deleted_and_audited_with_original_id(self) -> None:
integration = create_integration(
context=self.context,
data=custom_input(name="Свободный провайдер"),
)
integration_id = integration.id
response = self.client.delete(self._url(integration_id))
self.assertEqual(response.status_code, 204)
self.assertFalse(Integration.objects.filter(id=integration_id).exists())
self.assertTrue(
AuditEvent.objects.filter(
action="integrations.integration_deleted",
object_id=str(integration_id),
).exists()
)
+1 -1
View File
@@ -510,7 +510,7 @@ class OpenRouterProviderProxyTests(TestCase):
provider = OpenRouterProvider(api_key="sk-test", base_url="https://openrouter.ai/api/v1", proxy_url="http://proxy:8080")
# После рефакторинга общий HTTP-слой живёт в openai_http (ADR-HUB-0033 §7):
# После рефакторинга общий HTTP-слой живёт в openai_http:
# мокаем именно его build_opener.
+20 -5
View File
@@ -7,6 +7,7 @@ from rest_framework.views import APIView
from chatballs.api.permissions import HasCapability
from chatballs.i18n import t
from chatballs.identity.audit import record_audit_event
from chatballs.integrations.deletion import IntegrationInUse, delete_integration
from chatballs.integrations.models import Integration
from chatballs.integrations.selectors import (
integration_for_context,
@@ -16,7 +17,6 @@ from chatballs.integrations.serializers import integration_payload, restore_prox
from chatballs.integrations.services import (
IntegrationInput,
create_integration,
delete_integration,
test_integration,
update_integration,
)
@@ -68,13 +68,19 @@ def _validation_error(error: Exception) -> Response:
return Response({"detail": detail}, status=400)
def _audit(request: Request, action: str, integration: Integration) -> None:
def _audit(
request: Request,
action: str,
integration: Integration,
*,
object_id: int | None = None,
) -> None:
record_audit_event(
action=action,
actor=request.user,
organization=request.tenant_context.organization,
object_type="Integration",
object_id=str(integration.id),
object_id=str(integration.id if object_id is None else object_id),
request=request,
)
@@ -127,8 +133,17 @@ class IntegrationDetailView(APIView):
integration = self._get(request, integration_id)
except Integration.DoesNotExist:
return Response({"detail": t("settings.integration_not_found")}, status=404)
_audit(request, "integrations.integration_deleted", integration)
delete_integration(context=request.tenant_context, integration=integration)
integration_id = integration.id
try:
delete_integration(context=request.tenant_context, integration=integration)
except IntegrationInUse as error:
return Response({"detail": str(error)}, status=409)
_audit(
request,
"integrations.integration_deleted",
integration,
object_id=integration_id,
)
return Response(status=204)
+1 -1
View File
@@ -16,7 +16,7 @@ def provisioning_result_payload(
owner_state: str,
) -> dict[str, Any]:
"""Response shape for POST /api/v1/organizations. Never includes secrets,
tokens or invitation plaintext (SPEC-HUB-0021 §12/§13)."""
tokens or invitation plaintext."""
return {
"organization": {
"publicId": str(organization.public_id),
@@ -26,7 +26,7 @@ class ProvisioningCommand:
tax_profile: dict[str, Any] | None = None
def significant_fields(self) -> dict[str, Any]:
"""Canonical normalized values for request_hash (SPEC-HUB-0021 §11):
"""Canonical normalized values for request_hash:
transport metadata is excluded. Keys are sorted for a stable hash."""
data = asdict(self)
data["owner_email"] = data["owner_email"].strip().lower()
@@ -53,7 +53,7 @@ class ProvisioningResult:
def is_replay(provisioning: OrganizationProvisioning, command: ProvisioningCommand) -> bool:
"""COMPLETED/WAITING_FOR_OWNER with a matching request hash is an idempotent
replay; a differing hash is a conflict (SPEC-HUB-0021 §11)."""
replay; a differing hash is a conflict."""
return provisioning.request_hash == command.request_hash()
@@ -21,10 +21,10 @@ class ProvisioningStatus(models.TextChoices):
class OrganizationProvisioning(models.Model):
"""Platform-owned process record (SPEC-HUB-0021 §6). Idempotency key is
"""Platform-owned process record. Idempotency key is
globally unique; the same key + payload returns the existing result, while a
differing payload is a conflict. failure_details_safe must never contain
secrets, tokens or provider credentials (SPEC-HUB-0021 §6/§13)."""
secrets, tokens or provider credentials."""
idempotency_key = models.CharField(max_length=160, unique=True)
request_hash = models.CharField(max_length=128)
@@ -46,7 +46,7 @@ def provision_organization(
command: ProvisioningCommand,
operator: PlatformOperator,
) -> ProvisioningResult:
"""Single write boundary for tenant provisioning (SPEC-HUB-0021 §4).
"""Single write boundary for tenant provisioning.
Coordinates identity, audit and outbox in one
transaction (тариф удалён, ADR-CHATBALLS-0042 §2: организация создаётся без подписки). Tenant-owned rows are written under set_local_tenant(new_org.id)
@@ -201,7 +201,7 @@ def _provision_pending_owner(
command: ProvisioningCommand,
context: TenantContext,
) -> None:
# No password/dummy user is created (SPEC-HUB-0021 §8.2). An OWNER invitation
# No password/dummy user is created. An OWNER invitation
# is issued; the organization stays PENDING_OWNER until it is accepted.
issued = issue_invitation(
organization=org,
@@ -16,7 +16,7 @@ _REQUIRED_FIELDS = (
def parse_provisioning_body(
body: Any, *, idempotency_key: str, source: str
) -> tuple[ProvisioningCommand, str | None]:
"""Map the API body (SPEC-HUB-0021 §12) to a ProvisioningCommand. Returns
"""Map the API body to a ProvisioningCommand. Returns
(command, error_message). Validation is intentionally explicit (no DRF
serializers), mirroring identity/access_payloads.py."""
if not isinstance(body, dict):
+1 -1
View File
@@ -19,7 +19,7 @@ _IDEMPOTENCY_HEADER = "Idempotency-Key"
class OrganizationProvisionView(APIView):
"""POST <platform-host>/api/v1/organizations (SPEC-HUB-0021 §12).
"""POST <platform-host>/api/v1/organizations.
The first Platform API adapter: machine-to-machine provisioning via an
opaque platform token. Does not reuse tenant HasCapability (no membership
@@ -0,0 +1,31 @@
from django.core.exceptions import ValidationError
from chatballs.i18n import t
from chatballs.support_portals.models import PortalArticle
from chatballs.support_portals.statuses import ArticleStatus
def apply_article_curation(article: PortalArticle, data: dict) -> None:
if "sortOrder" in data:
article.sort_order = int(data["sortOrder"])
if "relatedArticleIds" not in data:
return
related_ids = data["relatedArticleIds"]
if (
not isinstance(related_ids, list)
or len(related_ids) > 5
or any(type(item) is not int for item in related_ids)
or len(set(related_ids)) != len(related_ids)
or article.id in related_ids
):
raise ValidationError({"relatedArticleIds": t("portals.invalid_article_data")})
valid_count = PortalArticle.objects.filter(
id__in=related_ids,
portal=article.portal,
locale=article.locale,
status=ArticleStatus.PUBLISHED,
published_revision__isnull=False,
).count()
if valid_count != len(related_ids):
raise ValidationError({"relatedArticleIds": t("portals.invalid_article_data")})
article.related_article_ids = related_ids
@@ -9,6 +9,7 @@ from django.utils.text import slugify
from chatballs.ai.indexing import reindex_portal_article
from chatballs.conversations.transports.base import guess_content_type, safe_filename
from chatballs.i18n import t
from chatballs.support_portals.article_curation import apply_article_curation
from chatballs.support_portals.content_markdown import normalize_file_links
from chatballs.support_portals.models import (
PortalArticle,
@@ -104,6 +105,7 @@ def create_article(
category=category,
slug=str(data.get("slug", "")).strip().lower(),
locale=str(data.get("locale", portal.default_locale)).strip().lower(),
sort_order=1000,
)
article.full_clean()
article.save()
@@ -143,8 +145,11 @@ def update_article(
)
article.slug = str(data.get("slug", article.slug)).strip().lower()
article.locale = str(data.get("locale", article.locale)).strip().lower()
apply_article_curation(article, data)
article.full_clean()
article.save(update_fields=["category", "slug", "locale", "updated_at"])
article.save(update_fields=[
"category", "slug", "locale", "sort_order", "related_article_ids", "updated_at",
])
return article
Whitespace-only changes.
Whitespace-only changes.
@@ -0,0 +1,96 @@
import json
import sys
from pathlib import Path
from django.core.management.base import BaseCommand, CommandError
from chatballs.support_portals.models import PortalArticle, SupportPortal
from chatballs.tenancy.context import TenantContext
from chatballs.tenancy.database import tenant_atomic
from chatballs.tenancy.ingress import support_portal_route
from chatballs.tenancy.lookup import load_organization
def checked_changes(portal: SupportPortal, plan: dict) -> list[PortalArticle]:
if plan.get("portal") != portal.slug:
raise CommandError("Portal slug differs from the curation plan")
locale = plan.get("locale")
order = plan.get("order")
related = plan.get("related")
if not isinstance(locale, str) or not isinstance(order, dict) or not isinstance(related, dict):
raise CommandError("Invalid curation plan")
articles = list(
portal.articles.filter(
locale=locale, status="PUBLISHED", published_revision__isnull=False
).select_related("category")
)
by_slug = {item.slug: item for item in articles}
ordered = [slug for slugs in order.values() for slug in slugs]
if (
len(ordered) != len(by_slug)
or len(set(ordered)) != len(ordered)
or set(ordered) != set(by_slug)
or set(related) != set(by_slug)
):
raise CommandError("Plan must cover every published article exactly once")
for category_slug, slugs in order.items():
if not isinstance(slugs, list) or any(
by_slug[slug].category.slug != category_slug for slug in slugs
):
raise CommandError(f"Wrong article category in {category_slug}")
for slug, targets in related.items():
if (
not isinstance(targets, list)
or len(targets) > 5
or len(set(targets)) != len(targets)
or slug in targets
or not set(targets) <= set(by_slug)
):
raise CommandError(f"Invalid related articles for {slug}")
changed = []
for slugs in order.values():
for position, slug in enumerate(slugs, start=1):
article = by_slug[slug]
target_ids = [by_slug[target].id for target in related[slug]]
if article.sort_order != position or article.related_article_ids != target_ids:
article.sort_order = position
article.related_article_ids = target_ids
changed.append(article)
return changed
class Command(BaseCommand):
help = "Validate or apply an editorial order and related articles to a public portal"
def add_arguments(self, parser):
parser.add_argument("--host", required=True)
parser.add_argument("--file", required=True, help="JSON plan path or - for stdin")
parser.add_argument("--apply", action="store_true")
def handle(self, *args, **options):
hostname = options["host"].strip().lower().rstrip(".")
route = support_portal_route(hostname)
organization = load_organization(route.organization_id) if route else None
if organization is None:
raise CommandError("Public portal host not found")
source = sys.stdin.read() if options["file"] == "-" else Path(options["file"]).read_text(encoding="utf-8")
try:
plan = json.loads(source)
except json.JSONDecodeError as error:
raise CommandError("Invalid JSON plan") from error
context = TenantContext.for_resource(organization)
with tenant_atomic(context):
portal = SupportPortal.objects.filter(
id=route.resource_id,
organization=organization,
status="PUBLISHED",
).first()
if portal is None:
raise CommandError("Published portal not found")
changed = checked_changes(portal, plan)
if options["apply"] and changed:
PortalArticle.objects.bulk_update(
changed, ["sort_order", "related_article_ids"]
)
action = "Updated" if options["apply"] else "Would update"
self.stdout.write(f"{action} {len(changed)} articles")
@@ -0,0 +1,20 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("support_portals", "0011_hosted_domains_follow_installation"),
]
operations = [
migrations.AddField(
model_name="portalarticle",
name="sort_order",
field=models.PositiveIntegerField(default=1000),
),
migrations.AddField(
model_name="portalarticle",
name="related_article_ids",
field=models.JSONField(blank=True, default=list),
),
]
@@ -153,6 +153,8 @@ class PortalArticle(TenantRelationModel):
)
slug = models.SlugField(max_length=96)
locale = models.CharField(max_length=16, default="ru")
sort_order = models.PositiveIntegerField(default=1000)
related_article_ids = models.JSONField(default=list, blank=True)
status = models.CharField(
max_length=16,
choices=ArticleStatus.choices,
@@ -58,7 +58,9 @@ class PublicPortalDetailView(PublicPortalView):
return Response({"detail": t("portals.not_found")}, status=404)
context, portal = resolved
with tenant_atomic(context):
counts = category_article_counts(portal, published_only=True)
counts = category_article_counts(
portal, published_only=True, locale=portal.default_locale
)
return Response(
{
"portal": public_portal_payload(portal),
@@ -85,6 +87,7 @@ class PublicArticleListView(PublicPortalView):
locale=locale,
category=str(request.query_params.get("category", "")),
query=str(request.query_params.get("q", "")).strip(),
direct_category=request.query_params.get("direct") == "1",
)
try:
limit = min(max(int(request.query_params.get("limit", 50)), 1), 100)
@@ -122,7 +125,17 @@ class PublicArticleDetailView(PublicPortalView):
).first()
if article is None:
return Response({"detail": t("portals.article_not_found")}, status=404)
return Response({"article": public_article_payload(article)})
payload = public_article_payload(article)
related = public_articles(portal, locale=locale).filter(
id__in=article.related_article_ids
)
by_id = {item.id: item for item in related}
payload["relatedArticles"] = [
public_article_payload(by_id[item_id], content=False)
for item_id in article.related_article_ids
if item_id in by_id
]
return Response({"article": payload})
class PublicArticleFeedbackView(PublicPortalView):
@@ -108,6 +108,7 @@ def public_articles(
locale: str,
category: str = "",
query: str = "",
direct_category: bool = False,
) -> QuerySet[PortalArticle]:
articles = (
PortalArticle.objects.filter(
@@ -118,13 +119,13 @@ def public_articles(
)
.select_related("category", "published_revision")
.prefetch_related("files")
.order_by("category__sort_order", "published_revision__title")
.order_by("category__sort_order", "sort_order", "published_revision__title", "id")
)
if category:
root = portal.categories.filter(slug=category).first()
if root is None:
return articles.none()
category_ids = descendant_category_ids(portal, root.id)
category_ids = {root.id} if direct_category else descendant_category_ids(portal, root.id)
articles = articles.filter(category_id__in=category_ids)
if query:
articles = articles.filter(
@@ -151,10 +152,12 @@ def descendant_category_ids(portal: SupportPortal, root_id: int) -> set[int]:
def category_article_counts(
portal: SupportPortal, *, published_only: bool = False
portal: SupportPortal, *, published_only: bool = False, locale: str | None = None
) -> dict[int, int]:
categories = list(portal.categories.all())
direct_query = PortalArticle.objects.filter(portal=portal)
if locale is not None:
direct_query = direct_query.filter(locale=locale)
if published_only:
direct_query = direct_query.filter(
status="PUBLISHED", published_revision__isnull=False
@@ -107,6 +107,8 @@ def article_payload(
"id": article.id,
"slug": article.slug,
"locale": article.locale,
"sortOrder": article.sort_order,
"relatedArticleIds": article.related_article_ids,
"status": article.status,
"category": category_payload(article.category),
"publishedRevision": (
@@ -0,0 +1,139 @@
import json
from io import StringIO
from unittest.mock import patch
from django.core.management import call_command
from chatballs.support_portals.tests.base import SupportPortalTestCase
class ArticleCurationTests(SupportPortalTestCase):
def setUp(self) -> None:
super().setUp()
portal = self.create_portal().json()["portal"]
self.portal_id = portal["id"]
self.portal_host = portal["hostedDomain"]
self.root = self._category("Начало", "start")
self.child = self._category("Продолжение", "next", parent_id=self.root)
def _category(self, name: str, slug: str, *, parent_id: int | None = None) -> int:
payload = {"name": name, "slug": slug}
if parent_id is not None:
payload["parentId"] = parent_id
response = self.client.post(
f"/api/v1/support/portals/{self.portal_id}/categories/",
payload,
format="json",
)
self.assertEqual(response.status_code, 201, response.content)
return response.json()["category"]["id"]
def _article(self, slug: str, title: str, category_id: int) -> int:
response = self.client.post(
f"/api/v1/support/portals/{self.portal_id}/articles/",
{
"categoryId": category_id,
"slug": slug,
"title": title,
"summary": title,
"content": title,
},
format="json",
)
self.assertEqual(response.status_code, 201, response.content)
article = response.json()["article"]
published = self.client.post(
f"/api/v1/support/portals/{self.portal_id}/articles/{article['id']}/publish/",
{"revisionId": article["revisions"][0]["id"]},
format="json",
)
self.assertEqual(published.status_code, 200, published.content)
return article["id"]
def test_order_related_articles_and_direct_category(self) -> None:
first = self._article("first", "Альфа", self.root)
second = self._article("second", "Бета", self.root)
child = self._article("child", "Гамма", self.child)
base = f"/api/v1/support/portals/{self.portal_id}/articles/"
for article_id, data in (
(first, {"sortOrder": 2, "relatedArticleIds": [child, second]}),
(second, {"sortOrder": 1}),
):
response = self.client.patch(
f"{base}{article_id}/", data, format="json"
)
self.assertEqual(response.status_code, 200, response.content)
invalid = self.client.patch(
f"{base}{first}/",
{"relatedArticleIds": [first]},
format="json",
)
self.assertEqual(invalid.status_code, 400)
self.client.post(
f"/api/v1/support/portals/{self.portal_id}/status/",
{"status": "PUBLISHED"},
format="json",
)
self.client.logout()
listing = self.client.get(
"/api/v1/help/articles/?category=start&direct=1",
HTTP_HOST=self.portal_host,
)
self.assertEqual(listing.status_code, 200, listing.content)
self.assertEqual(
[item["slug"] for item in listing.json()["items"]],
["second", "first"],
)
detail = self.client.get(
"/api/v1/help/articles/first/", HTTP_HOST=self.portal_host
)
self.assertEqual(detail.status_code, 200, detail.content)
self.assertEqual(
[item["slug"] for item in detail.json()["article"]["relatedArticles"]],
["child", "second"],
)
def test_curation_command_dry_run_and_apply(self) -> None:
first = self._article("first", "Альфа", self.root)
second = self._article("second", "Бета", self.root)
self.client.post(
f"/api/v1/support/portals/{self.portal_id}/status/",
{"status": "PUBLISHED"},
format="json",
)
plan = json.dumps({
"portal": "app-help",
"locale": "ru",
"order": {"start": ["second", "first"]},
"related": {"first": ["second"], "second": ["first"]},
})
output = StringIO()
with patch("sys.stdin", StringIO(plan)):
call_command(
"apply_portal_curation", host=self.portal_host, file="-", stdout=output
)
self.assertIn("Would update 2 articles", output.getvalue())
output = StringIO()
with patch("sys.stdin", StringIO(plan)):
call_command(
"apply_portal_curation", host=self.portal_host, file="-", apply=True,
stdout=output,
)
self.assertIn("Updated 2 articles", output.getvalue())
self.client.logout()
listing = self.client.get(
"/api/v1/help/articles/?category=start&direct=1",
HTTP_HOST=self.portal_host,
)
self.assertEqual(
[item["slug"] for item in listing.json()["items"]],
["second", "first"],
)
detail = self.client.get(
"/api/v1/help/articles/first/", HTTP_HOST=self.portal_host
)
self.assertEqual(
[item["slug"] for item in detail.json()["article"]["relatedArticles"]],
["second"],
)
self.assertNotEqual(first, second)
@@ -1,4 +1,4 @@
# C06 tenant provisioning DB boundary (SPEC-HUB-0021 §10, ADR-CHATBALLS-0029 §6).
# C06 tenant provisioning DB boundary (ADR-CHATBALLS-0029 §6).
#
# Provisioning runs on the platform connection (chatballs_runtime_platform) and must
# both INSERT a new Organization and create tenant-owned rows (departments, the
@@ -27,7 +27,7 @@ PLATFORM_POLICY = "chatballs_platform_tenant_provisioning"
def apply_grants(apps, schema_editor):
# 1. Platform role may create and update organizations (no RLS on this table;
# it has no organization_id column). DELETE stays with the schema/migration
# role per SPEC-HUB-0021 §14 (hard delete as compensation is forbidden).
# role (hard delete as compensation is forbidden).
schema_editor.execute(
"GRANT INSERT, UPDATE ON identity_organization TO chatballs_runtime_platform"
)
@@ -1,7 +1,7 @@
# Реестр демо-данных (identity_demodataset, identity_demorecord): tenant-таблицы
# с organization_id, RLS и гранты по образцу tenancy/0021. Платформенная роль
# получает те же права: мастер первого запуска ставит демо-набор в очередь на
# соединении platform (SPEC-HUB-0021 §10).
# соединении platform.
from django.db import migrations
TABLES = ("identity_demodataset", "identity_demorecord")
@@ -2,7 +2,7 @@
# после tenancy/0003, и ни одна миграция не выдала на них прав runtime-роли
# platform. В деплое backend-platform работает ролью chatballs_platform, поэтому
# уже проверка токена падала «permission denied for table platform_platformtoken»,
# а вместе с ней — весь POST /api/v1/organizations (SPEC-HUB-0021 §12). Тесты
# а вместе с ней — весь POST /api/v1/organizations. Тесты
# этого не видели: они ходят в базу владельцем кластера.
#
# Здесь роль platform получает DML на платформенные таблицы (без DELETE: токены
@@ -10,7 +10,7 @@
# нет»: проверку делает SECURITY DEFINER-функция от chatballs_schema (иначе
# политика ссылалась бы на свою же таблицу и Postgres отказал бы за рекурсию).
# После первой организации INSERT для app закрыт навсегда — создавать следующие
# по-прежнему может только роль platform (SPEC-HUB-0021 §10).
# по-прежнему может только роль platform.
from django.db import migrations
INGRESS_VIEWS = (
+2 -2
View File
@@ -57,7 +57,7 @@ class OrganizationStorageUsage(models.Model):
"""Authoritative storage_bytes usage counter for one organization.
``bytes_used`` is the committed total; ``reserved_bytes`` covers in-flight
uploads whose final size is not yet known (SPEC-HUB-0022 §10 reserve/finalize).
uploads whose final size is not yet known (reserve/finalize).
The effective usage against the quota is ``bytes_used + reserved_bytes``.
"""
@@ -88,7 +88,7 @@ class OrganizationStorageUsage(models.Model):
class StorageReservation(models.Model):
"""An in-flight storage_bytes reservation keyed by an idempotency token, so a
multi-step upload (SPEC-HUB-0022 §10) can reserve the expected size, finalize
multi-step upload can reserve the expected size, finalize
the actual size once the object is persisted, and release the reservation on
failure. A single reservation tracks one upload lifecycle.
"""
+2 -2
View File
@@ -1,7 +1,7 @@
"""Принудительный выбор соединения БД для блока кода.
Алиас ``platform`` (роль platform, права на создание организаций —
SPEC-HUB-0021) есть только у процессов, которым он нужен: у платформенной
Алиас ``platform`` (роль platform, права на создание
организаций) есть только у процессов, которым он нужен: у платформенной
поверхности и у воркера, захватывающего outbox всех организаций
(см. ``settings_database.build_databases``). У backend-app его нет: каталоги
входа и мастер первого запуска работают ролью app (tenancy/0032).
@@ -13,7 +13,7 @@ from chatballs.testing import system_tenant_context
class StorageReserveFinalizeReleaseTests(TestCase):
"""SPEC-HUB-0022 §10: reserve/finalize/release — идемпотентный технический
"""Reserve/finalize/release — идемпотентный технический
учёт занятого места. Лимитов больше нет (ADR-CHATBALLS-0042 §2): сервис никогда
не отказывает по квоте."""
+3
View File
@@ -273,6 +273,9 @@ LOADER_JS = r"""
function setOpen(next) {
ensureFrame();
// Слой силуэта нужен уже на первом кадре: первый же кадр рисуется до
// запуска анимации, чтобы панель не мелькнула целиком.
ensureGenie();
open = next;
window.clearTimeout(contentTimer);
btn.style.opacity = "0";
@@ -208,9 +208,6 @@ CHATBALLS_AI_TURN_DEADLINE_SECONDS = int(
)
# Модель расшифровки голосовых (OpenAI-совместимый /audio/transcriptions).
# Managed-провайдер CustoAI удалён (ADR-CHATBALLS-0042 §3): AI — только через
# интеграцию организации (BYOK).
# Long-poll hold-time мессенджеров (сек). Держим малым: опрос идёт по
# подключениям последовательно в одном процессе, и hold-time каждого из них
# складывается в задержку приёма у остальных. Ответы AI от этого больше не
@@ -267,7 +264,7 @@ CHATBALLS_STORAGE_BACKEND, MEDIA_ROOT, STORAGES = build_storage_settings(
testing=TESTING,
)
# Публичный адрес Hub: абсолютные ссылки, уходящие клиентам (download вложений).
# Публичный адрес установки: абсолютные ссылки, уходящие клиентам (download вложений).
CHATBALLS_PUBLIC_BASE_URL = os.environ.get("CHATBALLS_PUBLIC_BASE_URL", "http://localhost:8000")
# Публичные порталы поддержки размещаются на отдельных хостах. Базовый домен
@@ -323,7 +320,7 @@ if (
or CHATBALLS_CALL_CONNECT_GRACE_SECONDS <= 0
or CHATBALLS_CALL_RECONNECT_GRACE_SECONDS <= 0
):
raise ImproperlyConfigured("HUB call token TTL values must be positive")
raise ImproperlyConfigured("Call token TTL values must be positive")
# ICE-серверы для WebRTC (SPEC-CHATBALLS-0013 §10): direct-first через STUN, TURN как
# fallback. Формат URL через запятую (stun:host:port / turn:host:3478?transport=udp).
@@ -633,7 +633,7 @@
background: var(--n-9);
}
/* Блокер «провайдер не выбран» (кадр G5, ADR-HUB-0017). */
/* Блокер «провайдер не выбран» (кадр G5). */
.agent-blocker {
display: flex;
@@ -61,6 +61,7 @@ export function ChatPage({
? <Hint id="chat-visibility">{t("admin.see_every_conversation_organization_operators")}</Hint>
: undefined}
viewerId={user.id}
sender={{ operatorName: user.fullName, company: user.organizationName }}
renderContextPanel={({ dialog, detail, applyConversation, startCall, closeContext, assignmentTimeoutMinutes }) => (
<ChatContextPanel
rightTab={rightTab}
@@ -1,24 +1,25 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { useEffect, useRef, useState } from "react";
import { Icon, LogoSpinner } from "../../shared/icons";
import { ComposerTemplatesMenu } from "./ComposerTemplatesMenu";
import { EmojiPicker } from "./EmojiPicker";
import { useMediaQuery } from "../../shared/useMediaQuery";
import { fetchReplyTemplates, sendFileMessage, sendOperatorMessage, sendVoiceMessage, type ReplyTemplateRef } from "./model";
import { sendFileMessage, sendOperatorMessage, sendVoiceMessage } from "./model";
import { formatSize } from "../ai/knowledge/model";
import { formatDuration } from "./VoiceMessage";
import { TEMPLATE_VARIABLE_LABEL, unfilledVariables, type TemplateValues } from "./templateVariables";
import { useComposerTemplates } from "./useComposerTemplates";
import { useVoiceRecorder } from "./useVoiceRecorder";
import type { ChannelKey, ControlMode } from "./types";
import { t } from "../../i18n";
const MAX_FILE_BYTES = 20 * 1024 * 1024;
export function Composer({ mode, loaded, assignedOperatorName, conversationId, channel, voiceAllowed = true, onClaim, onRelease, onReturnQueue, onClose, onSent }: { mode: ControlMode; loaded: boolean; assignedOperatorName?: string; conversationId: number | null; channel?: ChannelKey; voiceAllowed?: boolean; onClaim: () => void; onRelease: () => void; onReturnQueue: () => void; onClose: () => void; onSent: () => void }) {
export function Composer({ mode, loaded, assignedOperatorName, conversationId, channel, voiceAllowed = true, templateValues = {}, onClaim, onRelease, onReturnQueue, onClose, onSent }: { mode: ControlMode; loaded: boolean; assignedOperatorName?: string; conversationId: number | null; channel?: ChannelKey; voiceAllowed?: boolean; templateValues?: TemplateValues; onClaim: () => void; onRelease: () => void; onReturnQueue: () => void; onClose: () => void; onSent: () => void }) {
const [text, setText] = useState("");
const compact = useMediaQuery("(max-width: 768px)");
const [sending, setSending] = useState(false);
const [sendError, setSendError] = useState("");
const [templates, setTemplates] = useState<ReplyTemplateRef[]>([]);
const [templatesOpen, setTemplatesOpen] = useState(false);
const [attachment, setAttachment] = useState<File | null>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
@@ -35,25 +36,10 @@ export function Composer({ mode, loaded, assignedOperatorName, conversationId, c
// Сброс черновика вложения при смене диалога.
useEffect(() => { setAttachment(null); setSendError(""); }, [conversationId]);
useEffect(() => {
fetchReplyTemplates().then(setTemplates).catch(() => setTemplates([]));
}, []);
// Шаблоны «/» (дизайн-базлайн v2 §9): ввод «/» в начале открывает список,
// продолжение ввода фильтрует по названию.
const slashQuery = text.startsWith("/") ? text.slice(1).trim().toLowerCase() : null;
const visibleTemplates = useMemo(() => {
if (templates.length === 0) return [];
if (slashQuery === null) return templates;
return templates.filter((template) => template.title.toLowerCase().includes(slashQuery));
}, [templates, slashQuery]);
const menuOpen = templatesOpen || (slashQuery !== null && visibleTemplates.length > 0);
function applyTemplate(template: ReplyTemplateRef) {
setText(template.text);
setTemplatesOpen(false);
textareaRef.current?.focus();
}
const templates = useComposerTemplates({ text, setText, values: templateValues, textareaRef });
// Переменная шаблона без значения (у гостя нет имени) остаётся в тексте:
// пока оператор её не заполнит, ответ не уходит.
const unfilled = unfilledVariables(text);
// Запись голосового: во всех каналах (TG/MAX sendVoice, почта — вложением, Web — поллингом).
const recorder = useVoiceRecorder({
@@ -111,7 +97,7 @@ export function Composer({ mode, loaded, assignedOperatorName, conversationId, c
async function send() {
const value = text.trim();
if ((!value && !attachment) || conversationId == null || sending) return;
if ((!value && !attachment) || conversationId == null || sending || unfilled.length > 0) return;
setSending(true);
setSendError("");
try {
@@ -163,7 +149,9 @@ export function Composer({ mode, loaded, assignedOperatorName, conversationId, c
// Кадры A–C: композер активен всегда (решение 2) — первое сообщение
// перехватывает диалог; над полем одна строка-предупреждение.
const warning = mode === "ai"
const warning = unfilled.length > 0
? { color: "var(--warning-text)", dot: "var(--warning)", text: t("conversations.fill_template_variables", { names: unfilled.map((name) => t(TEMPLATE_VARIABLE_LABEL[name]).toLocaleLowerCase()).join(", ") }) }
: mode === "ai"
? { color: "var(--ai)", text: t("conversations.ai_handling_conversation_message_takes") }
: mode === "waiting"
? { color: "var(--warning-text)", dot: "var(--warning)", text: t("conversations.customer_waiting_message_assigns_conversation") }
@@ -174,17 +162,7 @@ export function Composer({ mode, loaded, assignedOperatorName, conversationId, c
<div className="composer-wrap">
{warning && <div className="composer-warning" style={{ color: warning.color }}><i style={{ background: warning.dot ?? warning.color }} />{warning.text}</div>}
<div className="composer-box">
{menuOpen && (
<div className="composer-templates-menu">
{visibleTemplates.length === 0 && <p>{t("conversations.no_matching_templates")}</p>}
{visibleTemplates.map((template) => (
<button key={template.id} type="button" onMouseDown={(event) => { event.preventDefault(); applyTemplate(template); }}>
<strong>{template.title}</strong>
<small>{template.text.replace(/\s+/g, " ").slice(0, 80)}</small>
</button>
))}
</div>
)}
{templates.menuOpen && <ComposerTemplatesMenu items={templates.visible} onPick={templates.apply} />}
{attachment && (
<div className="composer-attachment">
<Icon name="paperclip" size={14} />
@@ -202,14 +180,14 @@ export function Composer({ mode, loaded, assignedOperatorName, conversationId, c
value={text}
onChange={(event) => setText(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Escape" && menuOpen) { setTemplatesOpen(false); if (slashQuery !== null) setText(""); return; }
if (event.key === "Escape" && templates.menuOpen) { templates.close(); if (templates.slashQuery !== null) setText(""); return; }
if (event.key === "Enter" && !event.shiftKey) {
if (slashQuery !== null && visibleTemplates.length > 0) { event.preventDefault(); applyTemplate(visibleTemplates[0]); return; }
if (templates.slashQuery !== null && templates.visible.length > 0) { event.preventDefault(); templates.apply(templates.visible[0]); return; }
event.preventDefault();
void send();
}
}}
onBlur={() => setTemplatesOpen(false)}
onBlur={templates.close}
onPaste={(event) => {
const file = Array.from(event.clipboardData?.files ?? [])[0];
if (file) { event.preventDefault(); pickFile(file); }
@@ -226,12 +204,12 @@ export function Composer({ mode, loaded, assignedOperatorName, conversationId, c
<Icon name="mic" size={17} />
</button>
)}
{templates.length > 0 && (
<button className="composer-tool is-labeled" title={t("conversations.reply_templates")} type="button" onClick={() => setTemplatesOpen((open) => !open)}>
{templates.available && (
<button className="composer-tool is-labeled" title={t("conversations.reply_templates")} type="button" onClick={templates.toggle}>
<Icon name="text" size={16} />{t("conversations.templates")}</button>
)}
<span className="composer-spacer" />
<button className="composer-send" type="button" onClick={() => void send()} disabled={sending || (!text.trim() && !attachment)}><span>{t("conversations.send")}</span><kbd>⏎</kbd><Icon name="send" size={17} /></button>
<button className="composer-send" type="button" onClick={() => void send()} disabled={sending || (!text.trim() && !attachment) || unfilled.length > 0}><span>{t("conversations.send")}</span><kbd>⏎</kbd><Icon name="send" size={17} /></button>
</div>
</div>
</div>
@@ -0,0 +1,16 @@
import type { ReplyTemplateRef } from "./model";
import { t } from "../../i18n";
export function ComposerTemplatesMenu({ items, onPick }: { items: ReplyTemplateRef[]; onPick: (template: ReplyTemplateRef) => void }) {
return (
<div className="composer-templates-menu">
{items.length === 0 && <p>{t("conversations.no_matching_templates")}</p>}
{items.map((template) => (
<button key={template.id} type="button" onMouseDown={(event) => { event.preventDefault(); onPick(template); }}>
<strong>{template.title}</strong>
<small>{template.text.replace(/\s+/g, " ").slice(0, 80)}</small>
</button>
))}
</div>
);
}
@@ -53,11 +53,11 @@ import { useDialogKeyboardNav } from "./useDialogKeyboardNav";
import { useIncomingMessageSound } from "./useIncomingMessageSound";
import { t } from "../../i18n";
// Общий workspace диалогов (SPEC-HUB-0010 §8.2). Видимость inbox решает
// Общий workspace диалогов. Видимость inbox решает
// backend по группам (ADR-CHATBALLS-0043); страница параметризуется заголовком,
// placeholder поиска и правой панелью через render-prop. Список и история —
// серверные окна: ни то, ни другое целиком не запрашивается.
export function ConversationWorkspace({ isOwner = false, canDelete = false, viewerId = null, listTitle, searchPlaceholder, renderContextPanel, mobileHeader, hint, initialConversationId, scope, setScope, counters, showScopeSwitcher = true }: {
export function ConversationWorkspace({ isOwner = false, canDelete = false, viewerId = null, listTitle, searchPlaceholder, renderContextPanel, mobileHeader, hint, initialConversationId, scope, setScope, counters, showScopeSwitcher = true, sender }: {
isOwner?: boolean;
/** Удалять диалоги могут владелец и администратор (то же проверяет сервер). */
canDelete?: boolean;
@@ -74,6 +74,8 @@ export function ConversationWorkspace({ isOwner = false, canDelete = false, view
setScope: (scope: DialogScope) => void;
counters: ConversationCounters | null;
showScopeSwitcher?: boolean;
/** Кто отвечает — для переменных шаблонов ответов. */
sender?: { operatorName: string; company: string };
}) {
const [listTab, setListTab] = useState<ListTab>("all");
const [sort, setSort] = useState<ListSort>("activity");
@@ -289,6 +291,11 @@ export function ConversationWorkspace({ isOwner = false, canDelete = false, view
mode={controlMode}
channel={selectedDialog?.channel}
voiceAllowed={detail?.connection?.voiceMessages ?? false}
templateValues={{
client_name: detail?.contact?.isGuest ? "" : detail?.contact?.name,
operator_name: sender?.operatorName,
company: sender?.company,
}}
loaded={detailLoaded}
assignedOperatorName={detail?.assignedOperator?.name}
conversationId={selectedId}
@@ -72,7 +72,8 @@ export type ApiConversation = {
connection: { id: number; provider: "EMAIL" | "MAX" | "TELEGRAM" | "VK" | "WEB"; name: string; voiceMessages?: boolean; audioCalls?: boolean; videoCalls?: boolean } | null;
// Контакт — единственный источник identity диалога.
// phone появляется после явного шаринга контакта; username (@логин TG/MAX) — только в detail-режиме.
contact: { id: number; name: string; email?: string; phone?: string; username?: string; avatarUrl?: string; description?: string; company?: string; city?: string } | null;
// isGuest — только в detail-режиме: имя гостя виджета — подпись «Гость · код», а не имя.
contact: { id: number; name: string; email?: string; phone?: string; username?: string; avatarUrl?: string; description?: string; company?: string; city?: string; isGuest?: boolean } | null;
lifecycle: "OPEN" | "CLOSED" | "SPAM";
controlMode: "AI" | "HUMAN" | "PAUSED";
expectedResponder: string;
@@ -371,6 +372,18 @@ export const createConversationLabel = (name: string, color = "") =>
}).then((r) => r.label);
export const fetchReplyTemplates = () =>
api<{ items: ReplyTemplateRef[] }>("/api/v1/conversations/templates/").then((r) => r.items);
export const createReplyTemplate = (title: string, text: string) =>
api<{ template: ReplyTemplateRef }>("/api/v1/conversations/templates/", {
method: "POST",
body: JSON.stringify({ title, text }),
}).then((r) => r.template);
export const updateReplyTemplate = (id: number, title: string, text: string) =>
api<{ template: ReplyTemplateRef }>(`/api/v1/conversations/templates/${id}/`, {
method: "PATCH",
body: JSON.stringify({ title, text }),
}).then((r) => r.template);
export const deleteReplyTemplate = (id: number) =>
api<void>(`/api/v1/conversations/templates/${id}/`, { method: "DELETE" });
export const transcribeMessage = (messageId: number) =>
api<{ message: ApiMessage }>(`/api/v1/conversations/messages/${messageId}/transcribe/`, { method: "POST" }).then((r) => r.message);
@@ -0,0 +1,24 @@
import { describe, expect, it } from "vitest";
import { renderTemplate, unfilledVariables } from "./templateVariables";
describe("переменные шаблона ответа", () => {
it("подставляет известные значения", () => {
const text = renderTemplate("Здравствуйте, {{client_name}}! Меня зовут {{ operator_name }}, {{company}}.", {
client_name: "Дмитрий",
operator_name: "Анна",
company: "Ателье Норд",
});
expect(text).toBe("Здравствуйте, Дмитрий! Меня зовут Анна, Ателье Норд.");
});
it("оставляет переменную без значения и чужие скобки как есть", () => {
const text = renderTemplate("Здравствуйте, {{client_name}}! {{unknown}}", { client_name: " ", company: "Ателье Норд" });
expect(text).toBe("Здравствуйте, {{client_name}}! {{unknown}}");
});
it("находит незаполненные переменные без повторов", () => {
expect(unfilledVariables("{{client_name}}, {{client_name}} и {{company}}, {{unknown}}")).toEqual(["client_name", "company"]);
expect(unfilledVariables("Готовый текст")).toEqual([]);
});
});
@@ -0,0 +1,41 @@
import type { MessageKey } from "../../i18n/ru";
// Переменные шаблонов ответов. В тексте шаблона хранится код, а не слово на
// языке интерфейса: язык организации меняется, код — нет. Тот же список
// проверяет сервер при сохранении (conversations/chat_extras_views.py).
export const TEMPLATE_VARIABLES = ["client_name", "operator_name", "company"] as const;
export type TemplateVariable = (typeof TEMPLATE_VARIABLES)[number];
export type TemplateValues = Partial<Record<TemplateVariable, string>>;
export const TEMPLATE_VARIABLE_LABEL: Record<TemplateVariable, MessageKey> = {
client_name: "conversations.variable_client_name",
operator_name: "conversations.variable_operator_name",
company: "conversations.variable_company",
};
const TOKEN = /\{\{\s*(\w+)\s*\}\}/g;
export function variableToken(variable: TemplateVariable): string {
return `{{${variable}}}`;
}
function isVariable(name: string): name is TemplateVariable {
return (TEMPLATE_VARIABLES as readonly string[]).includes(name);
}
/** Подставляет известные значения. Переменная без значения (у гостя нет
* имени) остаётся в тексте как есть — её заполнит оператор. */
export function renderTemplate(text: string, values: TemplateValues): string {
return text.replace(TOKEN, (token, name: string) => {
const value = isVariable(name) ? values[name]?.trim() : "";
return value || token;
});
}
/** Переменные, которые остались в тексте незаполненными, без повторов. */
export function unfilledVariables(text: string): TemplateVariable[] {
const found = [...text.matchAll(TOKEN)].map((match) => match[1]).filter(isVariable);
return [...new Set(found)];
}
@@ -1,4 +1,4 @@
// Общие типы workspace диалогов (sales + support). SPEC-HUB-0010 §8.2:
// Общие типы workspace диалогов (sales + support):
// общий conversation workspace, не отдельная реализация под каждый отдел.
export type DialogMode = "ai" | "closed" | "operator" | "wait";
@@ -0,0 +1,45 @@
import { useEffect, useMemo, useState, type RefObject } from "react";
import { fetchReplyTemplates, type ReplyTemplateRef } from "./model";
import { renderTemplate, type TemplateValues } from "./templateVariables";
// Шаблоны «/» (дизайн-базлайн v2 §9): ввод «/» в начале открывает список,
// продолжение ввода фильтрует по названию. Выбранный шаблон заменяет текст
// поля, переменные подставляются сразу — оператор видит готовый ответ.
export function useComposerTemplates({ text, setText, values, textareaRef }: {
text: string;
setText: (text: string) => void;
values: TemplateValues;
textareaRef: RefObject<HTMLTextAreaElement | null>;
}) {
const [templates, setTemplates] = useState<ReplyTemplateRef[]>([]);
const [open, setOpen] = useState(false);
useEffect(() => {
fetchReplyTemplates().then(setTemplates).catch(() => setTemplates([]));
}, []);
const slashQuery = text.startsWith("/") ? text.slice(1).trim().toLowerCase() : null;
const visible = useMemo(() => {
if (templates.length === 0) return [];
if (slashQuery === null) return templates;
return templates.filter((template) => template.title.toLowerCase().includes(slashQuery));
}, [templates, slashQuery]);
function apply(template: ReplyTemplateRef) {
setText(renderTemplate(template.text, values));
setOpen(false);
textareaRef.current?.focus();
}
return {
available: templates.length > 0,
visible,
slashQuery,
menuOpen: open || (slashQuery !== null && visible.length > 0),
toggle: () => setOpen((current) => !current),
close: () => setOpen(false),
apply,
};
}
@@ -4,6 +4,7 @@ import { fetchHelpArticle, sendArticleFeedback } from "./api";
import { HelpChevronIcon, HelpThumbIcon } from "./HelpIcons";
import { HelpLayout } from "./HelpLayout";
import { HelpAttachments } from "./HelpAttachments";
import { HelpRelatedArticles } from "./HelpRelatedArticles";
import { MarkdownContent, parseMarkdown } from "./MarkdownContent";
import type { HelpArticle as HelpArticleType, HelpManifest } from "./types";
import { t } from "../../i18n";
@@ -63,7 +64,9 @@ export function HelpArticle({
return (
<HelpLayout
compactHeader
portal={manifest.portal}
manifest={manifest}
activeCategoryId={article?.category.id}
activeArticleSlug={article?.slug}
search={search}
onSearchChange={setSearch}
onSearchSubmit={searchSubmit}
@@ -128,6 +131,7 @@ export function HelpArticle({
</>
)}
</section>
<HelpRelatedArticles articles={article.relatedArticles ?? []} />
</article>
{headings.length > 0 && (
<aside className="help-toc" aria-label={t("portals.article_contents")}>
@@ -12,6 +12,8 @@ import type { HelpManifest } from "./types";
import "./styles-layout.css";
import "./styles-home.css";
import "./styles-article.css";
import "./styles-navigation.css";
import "./styles-related.css";
import "./styles-responsive.css";
import { t } from "../../i18n";
@@ -80,7 +80,8 @@ export function HelpHome({ manifest }: { manifest: HelpManifest }) {
return (
<HelpLayout
portal={manifest.portal}
manifest={manifest}
activeCategoryId={selectedCategory?.id}
search={search}
onSearchChange={setSearch}
>
@@ -2,26 +2,32 @@ import type { ReactNode } from "react";
import { LogoIcon } from "../../shared/icons";
import { HelpSearch } from "./HelpSearch";
import { HelpNavigation } from "./HelpNavigation";
import { PortalWebWidget } from "./PortalWebWidget";
import type { HelpPortal } from "./types";
import type { HelpManifest } from "./types";
import { t } from "../../i18n";
export function HelpLayout({
portal,
manifest,
children,
search,
onSearchChange,
onSearchSubmit,
compactHeader = false,
activeCategoryId = null,
activeArticleSlug = null,
}: {
portal: HelpPortal;
manifest: HelpManifest;
children: ReactNode;
search: string;
onSearchChange: (value: string) => void;
onSearchSubmit?: (value: string) => void;
compactHeader?: boolean;
activeCategoryId?: number | null;
activeArticleSlug?: string | null;
}) {
const homeHref = "/";
const portal = manifest.portal;
return (
<div className={`help-center ${compactHeader ? "has-compact-header" : ""}`}>
<header className="help-header">
@@ -40,7 +46,14 @@ export function HelpLayout({
/>
)}
</header>
<main>{children}</main>
<main className="help-page-grid">
<HelpNavigation
manifest={manifest}
activeCategoryId={activeCategoryId}
activeArticleSlug={activeArticleSlug}
/>
<div className="help-page-content">{children}</div>
</main>
<footer className="help-footer">
<div className="help-footer-row">
<a className="help-footer-brand" href={homeHref}>
Loaded 100 of 127 files, more files were not shown because too many files have changed in this diff. Show more