Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file removed Integreat - Deutsch - Augsburg.pdf
Binary file not shown.
5 changes: 5 additions & 0 deletions integreat_cms/api/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -343,6 +343,11 @@
user_chat.is_chat_enabled_for_user,
name="is_chat_enabled_for_user",
),
path(
"chat/<slug:device_id>/attachment/<int:article_id>/<int:attachment_id>/",
user_chat.chat_attachment,
name="chat_attachment",
),
path("<slug:language_slug>/", include(content_api_urlpatterns)),
],
),
Expand Down
103 changes: 98 additions & 5 deletions integreat_cms/api/v3/chat/user_chat.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,13 @@
import json
import logging
import random
from io import BytesIO
from typing import TYPE_CHECKING

import requests
from django.conf import settings
from django.core.cache import cache
from django.http import HttpResponse, JsonResponse
from django.http import FileResponse, HttpResponse, JsonResponse
from django.shortcuts import get_object_or_404
from django.utils.dateparse import parse_datetime
from django.views.decorators.csrf import csrf_exempt
Expand All @@ -26,6 +27,7 @@
)

if TYPE_CHECKING:
from django.core.files.uploadedfile import UploadedFile
from django.http import HttpRequest

logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -89,6 +91,30 @@ def get_or_create_user_chat(
return None


def validate_attachments(attachments: list[UploadedFile]) -> str | None:
"""
Validate a list of uploaded chat attachments against the configured
size, MIME type and count restrictions.

:param attachments: uploaded files to validate
:return: an error message if validation fails, otherwise ``None``
"""
for attachment in attachments:
if (
attachment.size is None
or attachment.size > settings.INTEGREAT_CHAT_ATTACHMENT_MAX_SIZE
):
return f"Attachment '{attachment.name}' exceeds the maximum allowed size."
if (
attachment.content_type
not in settings.INTEGREAT_CHAT_ATTACHMENT_ALLOWED_MIME_TYPES
):
return f"Attachment '{attachment.name}' has an unsupported file type."
if len(attachments) > settings.INTEGREAT_CHAT_ATTACHMENT_MAX_COUNT:
return f"At most {settings.INTEGREAT_CHAT_ATTACHMENT_MAX_COUNT} attachments can be sent per message."
return None


def process_chat_payload(
request: HttpRequest, device_id: str, language_slug: str
) -> JsonResponse:
Expand All @@ -103,13 +129,20 @@ def process_chat_payload(
language = Language.objects.get(slug=language_slug)
if (user_chat := get_or_create_user_chat(request, device_id, language)) is None:
return JsonResponse({"error": "Chat not found."}, status=404)
if request.POST.get("message"):
attachments = request.FILES.getlist("attachment")
if attachments and (error := validate_attachments(attachments)) is not None:
return JsonResponse({"error": error}, status=400)
message_text = request.POST.get("message", "")
if message_text or attachments:
response = user_chat.save_message(
message=request.POST.get("message"), internal=False, automatic_message=False
message=message_text,
internal=False,
automatic_message=False,
attachments=attachments or None,
)
user_chat.language = language
user_chat.save()
if response is not None:
if message_text and response is not None:
if user_chat.automatic_answers:
user_chat.processing_answer = True # type: ignore[assignment]
celery_translate_and_answer_question.apply_async(
Expand All @@ -122,7 +155,7 @@ def process_chat_payload(
else:
celery_translate_question.apply_async(
args=[
request.POST.get("message"),
message_text,
request.region.slug,
response["ticket_id"],
]
Expand Down Expand Up @@ -176,6 +209,66 @@ def chat(
)


@csrf_exempt
@json_response
@rate_limit
def chat_attachment(
request: HttpRequest,
region_slug: str,
device_id: str,
article_id: int,
attachment_id: int,
) -> FileResponse | JsonResponse:
"""
Download an attachment from the current chat ticket of the given device.

The attachment must belong to a non-internal article of the device's current
Zammad ticket; otherwise a 404 is returned.

:param request: Django request
:param region_slug: slug of the region
:param device_id: ID of the device requesting the attachment
:param article_id: ID of the Zammad article the attachment belongs to
:param attachment_id: ID of the attachment within the article
:return: file response or JSON error
"""
if (
not request.region.integreat_chat_enabled
or not request.region.zammad_url
or not request.region.zammad_access_token
):
return JsonResponse(
{"error": "No chat server is configured for your region."},
status=503,
)
user_chat = UserChat.objects.current_chat(device_id, region=request.region)
if user_chat is None or user_chat.is_expired:
return JsonResponse({"error": "Chat not found."}, status=404)
try:
result = user_chat.get_attachment(article_id, attachment_id)
except (
requests.exceptions.HTTPError,
requests.exceptions.ConnectionError,
ValueError,
):
logger.exception("Could not connect to Zammad")
return JsonResponse(
{
"error": "An error occurred while attempting to connect to the chat server."
},
status=500,
)
if result is None:
return JsonResponse({"error": "Attachment not found."}, status=404)
content, content_type, filename = result
return FileResponse(
BytesIO(content),
content_type=content_type,
as_attachment=True,
filename=filename,
)


def is_app_user_message(webhook_message: dict) -> bool:
"""
Check if message originates from app user
Expand Down
111 changes: 97 additions & 14 deletions integreat_cms/cms/utils/zammad.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
Zammad API helper functions
"""

import base64
from abc import abstractmethod
from datetime import datetime
from typing import TYPE_CHECKING
Expand All @@ -11,6 +12,8 @@
from django.core.cache import cache

if TYPE_CHECKING:
from django.core.files.uploadedfile import UploadedFile

from ..models import Region


Expand Down Expand Up @@ -137,6 +140,15 @@ def clean_message(self, message: dict) -> dict:
)
message["user_is_author"] = message["role"] == "user"
message["content"] = message["body"]
message["attachments"] = [
{
"id": attachment["id"],
"filename": attachment["filename"],
"size": int(attachment.get("size", 0) or 0),
"content_type": self._attachment_content_type(attachment),
}
for attachment in (message.get("attachments") or [])
]
keys_to_keep = [
"status",
"error",
Expand All @@ -146,6 +158,7 @@ def clean_message(self, message: dict) -> dict:
"role",
"content",
"created_at",
"attachments",
]
return {key: message[key] for key in message if key in keys_to_keep}

Expand Down Expand Up @@ -182,6 +195,7 @@ def save_message(
internal: bool,
automatic_message: bool,
words_generated: int = 0,
attachments: "list[UploadedFile] | None" = None,
) -> dict | None:
"""
Save a new message (article) to a Zammad ticket.
Expand All @@ -190,36 +204,105 @@ def save_message(
:param internal: true if message should not be visible to app user
:param automatic_message: true if message does not originate from a human
:param num_words: number of words in the generated message
:param attachments: optional list of uploaded files to attach to the article
:return: Zammad ticket information
"""
self.update_mt_budget(words_generated)

cache.delete(f"{self.region.slug}_{self.device_id}")
payload: dict = {
"ticket_id": self.zammad_id,
"body": message,
"internal": internal,
"automatic_message": automatic_message,
"subject": (
"app user message"
if not automatic_message
else "automatically generated message"
),
"content_type": "text/html",
"type": "web",
"sender": "Customer" if not automatic_message else "Agent",
}
if attachments:
payload["attachments"] = [
{
"filename": attachment.name,
"data": base64.b64encode(attachment.read()).decode("ascii"),
"mime-type": attachment.content_type or "application/octet-stream",
}
for attachment in attachments
]
try:
response = self.zammad_request(
"POST",
"/api/v1/ticket_articles",
{
"ticket_id": self.zammad_id,
"body": message,
"internal": internal,
"automatic_message": automatic_message,
"subject": (
"app user message"
if not automatic_message
else "automatically generated message"
),
"content_type": "text/html",
"type": "web",
"sender": "Customer" if not automatic_message else "Agent",
},
payload,
)
except ValueError:
return None
if not response.ok:
return None
return response.json()

@staticmethod
def _attachment_content_type(attachment: dict) -> str:
"""
Determine the MIME type of a Zammad attachment.

Zammad reports the MIME type under the ``Mime-Type`` preference key for
every attachment, while ``Content-Type`` is only present for inline
images. ``Mime-Type`` is therefore used first, falling back to
``Content-Type`` and finally to ``application/octet-stream``.

:param attachment: attachment dict as returned by the Zammad API
:return: the MIME type of the attachment
"""
preferences = attachment.get("preferences") or {}
for key in ("Mime-Type", "Content-Type"):
content_type = preferences.get(key)
if content_type:
return content_type
return "application/octet-stream"

def get_attachment(
self, article_id: int, attachment_id: int
) -> tuple[bytes, str, str] | None:
"""
Fetch an attachment from a Zammad article that belongs to this ticket.

The article is first inspected to ensure it belongs to this ticket and is
not internal. Internal article attachments must never be exposed to app users.

:param article_id: ID of the Zammad article (message)
:param attachment_id: ID of the attachment within the article
:return: tuple of ``(content, content_type, filename)`` or ``None`` if
the attachment cannot be served
"""
try:
article = self.zammad_request(
"GET", f"/api/v1/ticket_articles/{article_id}"
).json()
except ValueError:
return None
if article.get("ticket_id") != self.zammad_id or article.get("internal"):
return None
attachment_meta = next(
(a for a in (article.get("attachments") or []) if a["id"] == attachment_id),
None,
)
if attachment_meta is None:
return None
try:
response = self.zammad_request(
"GET",
f"/api/v1/ticket_attachment/{self.zammad_id}/{article_id}/{attachment_id}",
)
except ValueError:
return None
content_type = self._attachment_content_type(attachment_meta)
return response.content, content_type, attachment_meta["filename"]

@property
def evaluation_consent(self) -> bool:
"""
Expand Down
23 changes: 22 additions & 1 deletion integreat_cms/core/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -1410,7 +1410,28 @@
#: the ``device_id`` attribute is set on Zammad tickets so that incoming webhooks
#: can be mapped to the correct chat regardless of the region.
MULTI_REGION_ZAMMAD: Final[bool] = bool(
strtobool(os.environ.get("INTEGREAT_CMS_MULTI_REGION_ZAMMAD", "False")),
strtobool(os.environ.get("INTEGREAT_CMS_MULTI_REGION_ZAMMAD", "False"))
)

#: Maximum size (bytes) of a single chat attachment uploaded by an Integreat app user
INTEGREAT_CHAT_ATTACHMENT_MAX_SIZE: Final[int] = env_int(
"INTEGREAT_CMS_INTEGREAT_CHAT_ATTACHMENT_MAX_SIZE",
10 * 1024 * 1024,
)

#: Maximum number of chat attachments per message from an Integreat app user
INTEGREAT_CHAT_ATTACHMENT_MAX_COUNT: Final[int] = env_int(
"INTEGREAT_CMS_INTEGREAT_CHAT_ATTACHMENT_MAX_COUNT", 5
)

#: Allowed MIME types for chat attachments uploaded by Integreat app users
INTEGREAT_CHAT_ATTACHMENT_ALLOWED_MIME_TYPES: Final[tuple[str, ...]] = (
"image/jpeg",
"image/png",
"image/gif",
"image/webp",
"application/pdf",
"text/plain",
)

##########
Expand Down
Loading