feat: add enterprise identity and channel integrations
This commit is contained in:
242
backend/apps/channels/api.py
Normal file
242
backend/apps/channels/api.py
Normal file
@@ -0,0 +1,242 @@
|
||||
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}
|
||||
Reference in New Issue
Block a user