from django.db import transaction from ninja import Router from ninja.errors import HttpError from apps.accounts.api import require_permission from apps.channels.models import ChannelEndpoint, ChannelMessage from apps.channels.schemas import ( ChannelEndpointIn, ChannelEndpointOut, ChannelEndpointUpdateIn, ChannelInboundIn, ChannelMessageOut, ChannelOutboundIn, ChannelReceiptIn, ) from apps.channels.services import ( ChannelError, ingest_inbound_message, queue_outbound_message, record_channel_receipt, verify_webhook_signature, ) from apps.modelhub.crypto import encrypt_secret router = Router(tags=["channels"]) def serialize_endpoint(endpoint: ChannelEndpoint) -> dict: return { "id": endpoint.id, "public_id": endpoint.public_id, "name": endpoint.name, "adapter_type": endpoint.adapter_type, "outbound_url": endpoint.outbound_url, "webhook_secret_set": bool(endpoint.webhook_secret_encrypted), "outbound_token_set": bool(endpoint.outbound_token_encrypted), "enabled": endpoint.enabled, "created_at": endpoint.created_at, "updated_at": endpoint.updated_at, } def serialize_message(message: ChannelMessage) -> dict: return { "public_id": message.public_id, "endpoint_id": message.endpoint_id, "endpoint_name": message.endpoint.name, "conversation_public_id": ( message.conversation.public_id if message.conversation else None ), "external_conversation_id": ( message.conversation.external_conversation_id if message.conversation else "" ), "ticket_id": message.ticket_id, "customer_reply_task_id": message.customer_reply_task_id, "reply_revision": message.reply_revision, "direction": message.direction, "external_message_id": message.external_message_id, "idempotency_key": message.idempotency_key, "sender": message.sender, "message_type": message.message_type, "text": message.text, "payload": message.payload, "status": message.status, "error_message": message.error_message, "occurred_at": message.occurred_at, "sent_at": message.sent_at, "delivered_at": message.delivered_at, "created_at": message.created_at, "updated_at": message.updated_at, } def get_endpoint(public_id: str) -> ChannelEndpoint: endpoint = ChannelEndpoint.objects.filter(public_id=public_id).first() if endpoint is None: raise HttpError(404, "渠道不存在") return endpoint @router.get("/endpoints", response=list[ChannelEndpointOut]) def list_channel_endpoints(request): require_permission(request, "channel.view") return [serialize_endpoint(item) for item in ChannelEndpoint.objects.all()] @router.post("/endpoints", response=ChannelEndpointOut) def create_channel_endpoint(request, payload: ChannelEndpointIn): require_permission(request, "channel.manage") if payload.adapter_type != "generic_webhook": raise HttpError(400, "首期仅支持 generic_webhook 适配器") if not payload.webhook_secret.strip(): raise HttpError(400, "Webhook 密钥不能为空") endpoint = ChannelEndpoint.objects.create( name=payload.name, adapter_type=payload.adapter_type, outbound_url=payload.outbound_url, webhook_secret_encrypted=encrypt_secret(payload.webhook_secret), outbound_token_encrypted=( encrypt_secret(payload.outbound_token) if payload.outbound_token else "" ), enabled=payload.enabled, created_by=request.user, ) return serialize_endpoint(endpoint) @router.patch("/endpoints/{endpoint_id}", response=ChannelEndpointOut) def update_channel_endpoint( request, endpoint_id: int, payload: ChannelEndpointUpdateIn, ): require_permission(request, "channel.manage") endpoint = ChannelEndpoint.objects.filter(id=endpoint_id).first() if endpoint is None: raise HttpError(404, "渠道不存在") changes = payload.dict(exclude_unset=True) for field in ("name", "outbound_url", "enabled"): if field in changes: setattr(endpoint, field, changes[field]) if changes.get("webhook_secret"): endpoint.webhook_secret_encrypted = encrypt_secret(changes["webhook_secret"]) if changes.get("outbound_token"): endpoint.outbound_token_encrypted = encrypt_secret(changes["outbound_token"]) endpoint.save() return serialize_endpoint(endpoint) @router.get("/messages", response=list[ChannelMessageOut]) def list_channel_messages( request, endpoint_id: int | None = None, direction: str = "", status: str = "", ): require_permission(request, "channel.view") queryset = ChannelMessage.objects.select_related("endpoint", "conversation") if endpoint_id: queryset = queryset.filter(endpoint_id=endpoint_id) if direction: queryset = queryset.filter(direction=direction) if status: queryset = queryset.filter(status=status) return [serialize_message(item) for item in queryset[:100]] @router.post( "/endpoints/{endpoint_id}/messages", response=ChannelMessageOut, ) def send_channel_message(request, endpoint_id: int, payload: ChannelOutboundIn): require_permission(request, "channel.send") require_permission(request, "ticket.reply") endpoint = ChannelEndpoint.objects.filter(id=endpoint_id).first() if endpoint is None: raise HttpError(404, "渠道不存在") try: message = queue_outbound_message( endpoint, payload.dict(), actor=request.user, ) except ChannelError as error: raise HttpError(400, str(error)) from error message = ChannelMessage.objects.select_related("endpoint", "conversation").get( id=message.id ) return serialize_message(message) @router.post("/messages/{public_id}/retry", response=ChannelMessageOut) def retry_channel_message(request, public_id: str): require_permission(request, "channel.send") require_permission(request, "ticket.reply") message = ( ChannelMessage.objects.select_related("endpoint", "conversation") .filter(public_id=public_id, direction="outbound") .first() ) if message is None: raise HttpError(404, "出站消息不存在") if message.status != "failed": raise HttpError(409, "仅失败消息可以重试") message.status = "queued" message.error_message = "" message.save(update_fields=["status", "error_message", "updated_at"]) def enqueue_delivery(): from apps.channels.tasks import deliver_channel_message_task deliver_channel_message_task.delay(message.id) transaction.on_commit(enqueue_delivery) return serialize_message(message) @router.post( "/webhooks/{public_id}/messages", response=ChannelMessageOut, auth=None, ) def receive_channel_message(request, public_id: str, payload: ChannelInboundIn): endpoint = get_endpoint(public_id) try: verify_webhook_signature( endpoint, request.body, request.headers.get("X-Channel-Signature", ""), ) except ChannelError as error: raise HttpError(401, str(error)) from error try: message = ingest_inbound_message(endpoint, payload.dict()) except ChannelError as error: raise HttpError(400, str(error)) from error return serialize_message( ChannelMessage.objects.select_related("endpoint", "conversation").get( id=message.id ) ) @router.post( "/webhooks/{public_id}/receipts", response={200: dict}, auth=None, ) def receive_channel_receipt(request, public_id: str, payload: ChannelReceiptIn): endpoint = get_endpoint(public_id) try: verify_webhook_signature( endpoint, request.body, request.headers.get("X-Channel-Signature", ""), ) receipt = record_channel_receipt(endpoint, payload.dict()) except ChannelError as error: raise HttpError(400, str(error)) from error return 200, {"status": "accepted", "receipt_id": receipt.id}