Files
ai-ticket-platform/backend/apps/channels/api.py

243 lines
8.1 KiB
Python

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}