Files
chatballs/apps/backend/hub_platform/support/session.py
T
Andrey 616343d986 feat(support): widget-credential and sessions messages endpoints
SPEC-HUB-0010 §7: виджет поддержки polling/send. Support-сессия не создаёт
WebSession (стартует по токену), а webchat/messages_payload ищет по contact
(support contact=null) — поэтому отдельные support-specific endpoints.

- widget_credential.py: stateless HMAC-signed credential {conversation_id,
  snapshot_id, exp}, TTL 1ч. Poll/send авторизуются им, не Product Support Token.
- messages.py: support_messages_since (по conversation, без contact-lookup) +
  post_support_message (Message(CONTACT) + AI-путь run_channel_turn/handoff,
  без Contact-creation и transports.send_reply — ответ идёт через polling).
- session.start_support_session: issue widget-credential, отдаётся в ответе
  sessions/ (widgetCredential).
- views: SupportSessionMessagesView GET/POST (Bearer widget-credential).
- urls: sessions/messages/.
- Тесты: start→credential, send→poll возвращает, invalid/no-credential→401,
  empty→400.
2026-07-09 16:47:51 +03:00

232 lines
8.7 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Проверка Product Support Token и старт support-сессии (SPEC-HUB-0011 §12).
Алгоритм start: channel checks → token verify → contract lookup → schema
validation → identity/operator/ai/search extraction → snapshot upsert →
create-or-continue Conversation → audit (success/denied). Raw token нигде
не сохраняется и не логируется — только короткий хэш jti.
"""
from __future__ import annotations
from typing import Any
from django.db import transaction
from django.http import HttpRequest
from hub_platform.conversations.models import (
ControlMode,
Conversation,
ExpectedResponder,
LifecycleState,
)
from hub_platform.identity.audit import record_audit_event
from hub_platform.identity.models import AuditResult, Organization
from hub_platform.support import errors
from hub_platform.support.extract import extract_context, validate_schema
from hub_platform.support.models import (
ContractStatus,
ProductSupportContract,
SupportIdentitySnapshot,
)
from hub_platform.support.selectors import contract_by_code
from hub_platform.support.token import TokenClaims, claims_datetimes, verify_support_token
from hub_platform.support.widget_credential import issue_widget_credential
def _deny_channel_policy(channel) -> errors.SupportSessionError | None:
"""Fail-closed проверки канала (SPEC §5.2)."""
if channel.department_id is None or channel.department.code != "support":
return errors.SupportSessionError(errors.CHANNEL_NOT_SUPPORT)
if channel.product_id is None:
return errors.SupportSessionError(errors.CHANNEL_PRODUCT_MISMATCH)
if channel.requires_authenticated_product_identity and not channel.allow_anonymous_sessions:
return None # корректный support-канал
return errors.SupportSessionError(errors.CHANNEL_NOT_SUPPORT)
def verify_and_resolve(
*, channel, token: str
) -> tuple[TokenClaims, ProductSupportContract, dict[str, Any]]:
"""Шаги 1–13 алгоритма: channel → token → contract → schema → mapping.
Возвращает (claims, contract, extracted) без записи в БД. Raw token не трогаем.
"""
channel_error = _deny_channel_policy(channel)
if channel_error is not None:
raise channel_error
product = channel.product
secret = product.support_token_secret or ""
claims = verify_support_token(token=token, secret=secret)
# iss должен совпадать с кодом продукта канала.
if claims.iss != product.code:
raise errors.SupportSessionError(errors.CHANNEL_PRODUCT_MISMATCH)
contract = contract_by_code(organization_id=channel.organization_id, code=claims.contract)
if contract is None:
raise errors.SupportSessionError(errors.CONTRACT_NOT_FOUND)
# DRAFT и DISABLED не принимают production traffic (SPEC-HUB-0011 §3).
if contract.status in {ContractStatus.DRAFT, ContractStatus.DISABLED}:
raise errors.SupportSessionError(errors.CONTRACT_DISABLED)
if not contract.allowed_channels.filter(id=channel.id).exists():
raise errors.SupportSessionError(errors.CONTRACT_CHANNEL_NOT_ALLOWED)
validate_schema(claims.data, contract.schema_json)
extracted = extract_context(claims.data, contract)
return claims, contract, extracted
def start_support_session(
*, channel, token: str, request: HttpRequest | None = None
) -> dict[str, Any]:
"""Создаёт/обновляет snapshot и создаёт/продолжает support Conversation.
При любой ошибке проверки пишет audit (DENIED) с машинным кодом и хэшем jti,
затем поднимает SupportSessionError. Raw token не попадает в audit.
Audit-deny пишется вне write-транзакции: иначе откат при raise уничтожил бы
запись. Успешный путь (snapshot + conversation + audit-success) атомарен.
"""
organization: Organization = channel.organization
try:
claims, contract, extracted = verify_and_resolve(channel=channel, token=token)
except errors.SupportSessionError as error:
_audit_denied(
organization=organization, channel=channel, request=request,
error=error, token=token,
)
raise
return _commit_session(
organization=organization, channel=channel, contract=contract,
claims=claims, extracted=extracted, request=request,
)
@transaction.atomic
def _commit_session(
*, organization, channel, contract, claims, extracted, request
) -> dict[str, Any]:
issued_at, expires_at = claims_datetimes(claims)
snapshot = _upsert_snapshot(
organization=organization,
channel=channel,
contract=contract,
claims=claims,
extracted=extracted,
issued_at=issued_at,
expires_at=expires_at,
)
conversation = _create_or_continue_conversation(channel=channel, snapshot=snapshot)
record_audit_event(
action="support.session_started",
result=AuditResult.SUCCESS,
organization=organization,
object_type="Conversation",
object_id=str(conversation.id),
payload={
"contract": contract.code,
"subject_key": extracted["subject_key"],
"product": channel.product.code,
"jti_hash": claims.jti_hash[:16],
},
request=request,
)
widget_credential = issue_widget_credential(
conversation_id=conversation.id, snapshot_id=snapshot.id
)
return {
"conversation": conversation,
"snapshot": snapshot,
"widget_credential": widget_credential,
}
def _audit_denied(
*, organization, channel, request, error: errors.SupportSessionError, token: str
) -> None:
# jti_hash безопасен; iss/contract неизвестны до verify — извлекаем только jti без raw token.
jti_hash = _safe_jti_hash(token)
record_audit_event(
action="support.session_denied",
result=AuditResult.DENIED,
organization=organization,
object_type="Channel",
object_id=str(channel.id),
payload={"code": error.code, "jti_hash": jti_hash},
request=request,
)
def _safe_jti_hash(token: str) -> str:
"""Хэш jti без раскрытия raw token: best-effort извлечение payload для audit."""
try:
parts = token.split(".")
if len(parts) != 3:
return ""
import base64
import hashlib
import json as _json
padding = "=" * (-len(parts[1]) % 4)
payload = _json.loads(base64.urlsafe_b64decode(parts[1] + padding))
jti = payload.get("jti", "")
return hashlib.sha256(str(jti).encode("utf-8")).hexdigest()[:16] if jti else ""
except Exception:
return ""
def _upsert_snapshot(
*, organization, channel, contract, claims, extracted, issued_at, expires_at
) -> SupportIdentitySnapshot:
snapshot, _created = SupportIdentitySnapshot.objects.update_or_create(
product=channel.product,
subject_key=extracted["subject_key"],
defaults={
"organization": organization,
"contract": contract,
"contract_code": contract.code,
"account_key": extracted["account_key"] or None,
"display_name": extracted["display_name"],
"display_email": extracted["display_email"],
"payload_json": claims.data,
"operator_context_json": extracted["operator_context"],
"ai_context_json": extracted["ai_context"],
"search_text": extracted["search_text"],
"token_issued_at": issued_at,
"token_expires_at": expires_at,
"token_jti_hash": claims.jti_hash,
},
)
return snapshot
def _create_or_continue_conversation(*, channel, snapshot) -> Conversation:
conversation = (
Conversation.objects.filter(
channel=channel, support_identity_snapshot=snapshot, lifecycle=LifecycleState.OPEN
)
.order_by("-last_activity_at")
.first()
)
if conversation is not None:
return conversation
previous = (
Conversation.objects.filter(
channel=channel, support_identity_snapshot=snapshot
)
.order_by("-created_at")
.first()
)
return Conversation.objects.create(
organization=channel.organization,
channel=channel,
contact=None,
support_identity_snapshot=snapshot,
control_mode=ControlMode.AI,
expected_responder=ExpectedResponder.AI,
previous_conversation=previous,
)