mirror of
https://github.com/dartdavros/chatballs.git
synced 2026-10-05 09:14:58 +03:00
236 lines
11 KiB
Python
236 lines
11 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,
|
|
)
|
|
from hub_platform.subscriptions.keys import QuotaKey
|
|
from hub_platform.subscriptions.usage_service import record_usage
|
|
from hub_platform.tenancy.context import TenantContext
|
|
|
|
|
|
@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)
|
|
is_active: bool | None = None
|
|
|
|
|
|
def _resolve_channel(
|
|
organization: Organization,
|
|
channel_id: int | None,
|
|
*,
|
|
current_channel_id: int | None = None,
|
|
):
|
|
if not channel_id:
|
|
return None
|
|
from hub_platform.channels.models import Channel
|
|
|
|
try:
|
|
channel = Channel.objects.get(organization=organization, id=channel_id)
|
|
except Channel.DoesNotExist as error:
|
|
raise ValidationError({"channel": "Channel not found"}) from error
|
|
if not channel.is_active and channel.id != current_channel_id:
|
|
raise ValidationError({"channel": "Inactive channel cannot accept connections"})
|
|
return channel
|
|
|
|
|
|
def _email_config(config: dict) -> dict:
|
|
"""Email-подключение (ADR-HUB-0035): адрес и хосты IMAP/SMTP обязательны,
|
|
порты/SSL имеют значения по умолчанию, purpose не поддерживается."""
|
|
if str(config.get("purpose", "")).strip():
|
|
raise ValidationError({"config": "Email cannot be a notifications bot"})
|
|
address = str(config.get("email", "")).strip().lower()
|
|
imap_host = str(config.get("imapHost", config.get("imap_host", ""))).strip()
|
|
smtp_host = str(config.get("smtpHost", config.get("smtp_host", ""))).strip()
|
|
if not address or not imap_host or not smtp_host:
|
|
raise ValidationError({"config": "Email address, IMAP host and SMTP host are required"})
|
|
try:
|
|
imap_port = int(config.get("imapPort", config.get("imap_port")) or 993)
|
|
smtp_port = int(config.get("smtpPort", config.get("smtp_port")) or 465)
|
|
except (TypeError, ValueError) as error:
|
|
raise ValidationError({"config": "Ports must be numbers"}) from error
|
|
return {
|
|
"email": address,
|
|
"imap_host": imap_host,
|
|
"imap_port": imap_port,
|
|
"imap_ssl": bool(config.get("imapSsl", config.get("imap_ssl", True))),
|
|
"smtp_host": smtp_host,
|
|
"smtp_port": smtp_port,
|
|
"smtp_ssl": bool(config.get("smtpSsl", config.get("smtp_ssl", True))),
|
|
}
|
|
|
|
|
|
def _normalized_config(provider: str, config: dict) -> dict:
|
|
if not isinstance(config, dict):
|
|
raise ValidationError({"config": "Object required"})
|
|
if provider == IntegrationProvider.EMAIL:
|
|
return _email_config(config)
|
|
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
|
|
# LLM-провайдеры (OpenRouter, Custom) хранят модель по умолчанию свободным текстом.
|
|
# Для OpenRouter поле исторически декоративно (SPEC-HUB-0005:388); для Custom оно
|
|
# читается в рантайме (ADR-HUB-0034 §4). Версионирование модели — дорожка ADR-0034.
|
|
if provider in (IntegrationProvider.OPENROUTER, IntegrationProvider.CUSTOM):
|
|
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
|
|
if provider == IntegrationProvider.CUSTOM:
|
|
if not base_url:
|
|
raise ValidationError({"config": "Custom Base URL is required"})
|
|
if "default_model" not in result:
|
|
raise ValidationError({"config": "Custom model is required"})
|
|
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(*, context: TenantContext, data: IntegrationInput) -> Integration:
|
|
organization = context.organization
|
|
provider = _validate_provider(data.provider)
|
|
name = data.name.strip()
|
|
if not name:
|
|
raise ValidationError({"name": "Name required"})
|
|
if provider == IntegrationProvider.CUSTOM and not (data.secret or "").strip():
|
|
raise ValidationError({"secret": "Custom API key is required"})
|
|
if provider == IntegrationProvider.EMAIL and not (data.secret or "").strip():
|
|
raise ValidationError({"secret": "Mailbox password is 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),
|
|
is_active=True if data.is_active is None else data.is_active,
|
|
status=IntegrationStatus.UNCHECKED,
|
|
)
|
|
integration.full_clean(exclude=["secret"])
|
|
integration.save()
|
|
record_usage(
|
|
context=context,
|
|
quota_key=QuotaKey.CLIENT_CONNECTIONS,
|
|
quantity=1,
|
|
idempotency_key=f"connection:{integration.id}",
|
|
source="integration.created",
|
|
aggregate_type="Integration",
|
|
aggregate_id=str(integration.id),
|
|
)
|
|
return integration
|
|
|
|
|
|
@transaction.atomic
|
|
def update_integration(
|
|
*, context: TenantContext, integration: Integration, data: IntegrationInput
|
|
) -> Integration:
|
|
if integration.organization_id != context.organization_id:
|
|
raise ValidationError({"integration": "Integration belongs to another organization"})
|
|
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,
|
|
current_channel_id=integration.channel_id,
|
|
)
|
|
if data.is_active is not None:
|
|
integration.is_active = data.is_active
|
|
# Пустой/отсутствующий секрет при обновлении не затирает существующий.
|
|
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(*, context: TenantContext, integration: Integration) -> None:
|
|
if integration.organization_id != context.organization_id:
|
|
raise ValidationError({"integration": "Integration belongs to another organization"})
|
|
integration.delete()
|
|
|
|
|
|
_CHECKS = {
|
|
IntegrationProvider.OPENROUTER: checks.check_openrouter,
|
|
IntegrationProvider.CUSTOM: checks.check_custom,
|
|
IntegrationProvider.MAX: checks.check_max,
|
|
IntegrationProvider.TELEGRAM: checks.check_telegram,
|
|
}
|
|
|
|
|
|
def _check_web(context: TenantContext, 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(context, 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(*, context: TenantContext, integration: Integration) -> Integration:
|
|
if integration.organization_id != context.organization_id:
|
|
raise ValidationError({"integration": "Integration belongs to another 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, "Проверка для этого типа подключения не поддерживается", {}
|
|
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
|