mirror of
https://github.com/dartdavros/chatballs.git
synced 2026-10-08 18:35:00 +03:00
- командный центр: карточки отделов разделены отступом - «Проверить» доступна для Web-виджета: backend валидирует привязку к каналу и что виджет канала обслуживает именно это подключение; во frontend флаг checkable отделён от testable (секрета у WEB нет) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
155 lines
6.5 KiB
Python
155 lines
6.5 KiB
Python
from dataclasses import dataclass, field
|
|
|
|
from django.core.exceptions import ValidationError
|
|
from django.db import transaction
|
|
from django.utils import timezone
|
|
|
|
from hub_platform.identity.models import Organization
|
|
from hub_platform.integrations import checks
|
|
from hub_platform.integrations.models import (
|
|
PROVIDER_KIND,
|
|
Integration,
|
|
IntegrationProvider,
|
|
IntegrationStatus,
|
|
)
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class IntegrationInput:
|
|
provider: str
|
|
name: str
|
|
secret: str | None = None # None = не менять при update
|
|
config: dict = field(default_factory=dict)
|
|
channel_id: int | None = None # канал обработки для подключения (ADR-HUB-0019)
|
|
|
|
|
|
def _resolve_channel(organization: Organization, channel_id: int | None):
|
|
if not channel_id:
|
|
return None
|
|
from hub_platform.channels.models import Channel
|
|
|
|
try:
|
|
return Channel.objects.get(organization=organization, id=channel_id)
|
|
except Channel.DoesNotExist as error:
|
|
raise ValidationError({"channel": "Channel not found"}) from error
|
|
|
|
|
|
def _normalized_config(provider: str, config: dict) -> dict:
|
|
if not isinstance(config, dict):
|
|
raise ValidationError({"config": "Object required"})
|
|
base_url = str(config.get("baseUrl", config.get("base_url", ""))).strip()
|
|
result: dict[str, str] = {}
|
|
if base_url:
|
|
result["base_url"] = base_url
|
|
proxy_url = str(config.get("proxyUrl", config.get("proxy_url", ""))).strip()
|
|
if proxy_url:
|
|
result["proxy_url"] = proxy_url
|
|
if provider == IntegrationProvider.OPENROUTER:
|
|
default_model = str(config.get("defaultModel", config.get("default_model", ""))).strip()
|
|
if default_model:
|
|
result["default_model"] = default_model
|
|
else:
|
|
# Мессенджер-подключения (MAX/Telegram/Web): идентификатор бота.
|
|
bot_username = str(config.get("botUsername", config.get("bot_username", ""))).strip()
|
|
if bot_username:
|
|
result["bot_username"] = bot_username
|
|
# Сервисный бот уведомлений для сотрудников (не привязан к каналу продаж).
|
|
purpose = str(config.get("purpose", "")).strip()
|
|
if purpose:
|
|
result["purpose"] = purpose
|
|
return result
|
|
|
|
|
|
def _validate_provider(provider: str) -> str:
|
|
if provider not in IntegrationProvider.values:
|
|
raise ValidationError({"provider": "Unknown provider"})
|
|
return provider
|
|
|
|
|
|
@transaction.atomic
|
|
def create_integration(*, organization: Organization, data: IntegrationInput) -> Integration:
|
|
provider = _validate_provider(data.provider)
|
|
name = data.name.strip()
|
|
if not name:
|
|
raise ValidationError({"name": "Name required"})
|
|
integration = Integration(
|
|
organization=organization,
|
|
kind=PROVIDER_KIND[provider],
|
|
provider=provider,
|
|
name=name,
|
|
secret=(data.secret or "").strip(),
|
|
config=_normalized_config(provider, data.config),
|
|
channel=_resolve_channel(organization, data.channel_id),
|
|
status=IntegrationStatus.UNCHECKED,
|
|
)
|
|
integration.full_clean(exclude=["secret"])
|
|
integration.save()
|
|
return integration
|
|
|
|
|
|
@transaction.atomic
|
|
def update_integration(*, integration: Integration, data: IntegrationInput) -> Integration:
|
|
integration.name = data.name.strip() or integration.name
|
|
integration.config = _normalized_config(integration.provider, data.config)
|
|
integration.channel = _resolve_channel(integration.organization, data.channel_id)
|
|
# Пустой/отсутствующий секрет при обновлении не затирает существующий.
|
|
if data.secret:
|
|
integration.secret = data.secret.strip()
|
|
integration.status = IntegrationStatus.UNCHECKED
|
|
integration.last_checked_at = None
|
|
integration.last_error = ""
|
|
integration.full_clean(exclude=["secret"])
|
|
integration.save()
|
|
return integration
|
|
|
|
|
|
def delete_integration(*, integration: Integration) -> None:
|
|
integration.delete()
|
|
|
|
|
|
_CHECKS = {
|
|
IntegrationProvider.OPENROUTER: checks.check_openrouter,
|
|
IntegrationProvider.MAX: checks.check_max,
|
|
IntegrationProvider.TELEGRAM: checks.check_telegram,
|
|
}
|
|
|
|
|
|
def _check_web(integration: Integration) -> tuple[bool, str, dict]:
|
|
"""Web-виджет обслуживается нашим же backend'ом — внешнего API нет.
|
|
Проверяем конфигурацию: привязку к каналу и что именно это подключение
|
|
отдаётся виджету (webchat берёт первое WEB-подключение канала)."""
|
|
if integration.channel_id is None:
|
|
return False, "Подключение не привязано к каналу — виджет не активен", {}
|
|
from hub_platform.webchat.services import web_connection_for_channel
|
|
|
|
active = web_connection_for_channel(integration.channel.code)
|
|
if active is None or active.id != integration.id:
|
|
return False, "Для этого канала виджет обслуживает другое WEB-подключение", {}
|
|
return True, f"Web-виджет активен · канал «{integration.channel.name}»", {}
|
|
|
|
|
|
def test_integration(*, integration: Integration) -> Integration:
|
|
if integration.provider == IntegrationProvider.WEB:
|
|
ok, detail, meta = _check_web(integration)
|
|
else:
|
|
check = _CHECKS.get(integration.provider)
|
|
if check is None:
|
|
ok, detail, meta = False, "Проверка для этого типа подключения не поддерживается", {}
|
|
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)
|
|
return integration
|