235 lines
7.9 KiB
Python
235 lines
7.9 KiB
Python
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,
|
|
retry_outbound_message,
|
|
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, "出站消息不存在")
|
|
try:
|
|
message = retry_outbound_message(message, actor=request.user)
|
|
except ChannelError as error:
|
|
raise HttpError(409, str(error)) from error
|
|
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}
|