django-chat-ui 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- django_chat_ui/__init__.py +2 -0
- django_chat_ui/admin.py +86 -0
- django_chat_ui/agui.py +180 -0
- django_chat_ui/apps.py +19 -0
- django_chat_ui/backend.py +269 -0
- django_chat_ui/content.py +215 -0
- django_chat_ui/locale/es/LC_MESSAGES/django.mo +0 -0
- django_chat_ui/locale/es/LC_MESSAGES/django.po +571 -0
- django_chat_ui/management/__init__.py +0 -0
- django_chat_ui/management/commands/__init__.py +0 -0
- django_chat_ui/management/commands/chat_ui_prune.py +22 -0
- django_chat_ui/matrix.py +275 -0
- django_chat_ui/migrations/0001_initial.py +225 -0
- django_chat_ui/migrations/__init__.py +0 -0
- django_chat_ui/models.py +480 -0
- django_chat_ui/py.typed +0 -0
- django_chat_ui/rules.py +345 -0
- django_chat_ui/services.py +644 -0
- django_chat_ui/static/django_chat_ui/boot.js +61 -0
- django_chat_ui/static/django_chat_ui/chat-inbox.js +270 -0
- django_chat_ui/static/django_chat_ui/chat-thread.js +886 -0
- django_chat_ui/static/django_chat_ui/chat-widget.js +229 -0
- django_chat_ui/static/django_chat_ui/chat.css +894 -0
- django_chat_ui/static/django_chat_ui/client.js +370 -0
- django_chat_ui/static/django_chat_ui/content.js +422 -0
- django_chat_ui/static/django_chat_ui/dom.js +13 -0
- django_chat_ui/static/django_chat_ui/homeserver.js +493 -0
- django_chat_ui/static/django_chat_ui/matrix.js +75 -0
- django_chat_ui/static/django_chat_ui/preview.js +47 -0
- django_chat_ui/static/django_chat_ui/store.js +358 -0
- django_chat_ui/static/django_chat_ui/theme.js +123 -0
- django_chat_ui/sync.py +438 -0
- django_chat_ui/templates/django_chat_ui/chat_ui.html +4 -0
- django_chat_ui/templatetags/__init__.py +0 -0
- django_chat_ui/templatetags/chat_ui.py +38 -0
- django_chat_ui/texts.py +76 -0
- django_chat_ui/urls.py +74 -0
- django_chat_ui/views.py +699 -0
- django_chat_ui-0.1.0.dist-info/METADATA +470 -0
- django_chat_ui-0.1.0.dist-info/RECORD +42 -0
- django_chat_ui-0.1.0.dist-info/WHEEL +4 -0
- django_chat_ui-0.1.0.dist-info/licenses/LICENSE +21 -0
django_chat_ui/admin.py
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""The conversations in the admin: to look at them, not to chat (changes
|
|
2
|
+
made here reach no open interface; use the services or the chat).
|
|
3
|
+
|
|
4
|
+
With models of a site's own (``CHAT_UI_CONVERSATION_MODEL``,
|
|
5
|
+
``CHAT_UI_PARTICIPANT_MODEL``), register them with these classes, as one
|
|
6
|
+
does with ``UserAdmin``:
|
|
7
|
+
|
|
8
|
+
@admin.register(Ticket)
|
|
9
|
+
class TicketAdmin(ConversationAdmin):
|
|
10
|
+
list_display = [*ConversationAdmin.list_display, "priority"]
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from django.contrib import admin
|
|
14
|
+
|
|
15
|
+
from .models import (
|
|
16
|
+
Conversation,
|
|
17
|
+
Media,
|
|
18
|
+
Membership,
|
|
19
|
+
Participant,
|
|
20
|
+
RoomEvent,
|
|
21
|
+
StreamEntry,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class ParticipantAdmin(admin.ModelAdmin):
|
|
26
|
+
list_display = ["__str__", "kind", "handle", "user", "created"]
|
|
27
|
+
list_filter = ["kind"]
|
|
28
|
+
search_fields = ["name", "handle"]
|
|
29
|
+
raw_id_fields = ["user"]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class MembershipInline(admin.TabularInline):
|
|
33
|
+
model = Membership
|
|
34
|
+
extra = 0
|
|
35
|
+
raw_id_fields = ["participant", "last_read"]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class RoomEventInline(admin.TabularInline):
|
|
39
|
+
model = RoomEvent
|
|
40
|
+
fk_name = "conversation"
|
|
41
|
+
extra = 0
|
|
42
|
+
fields = ["created", "sender", "type", "content"]
|
|
43
|
+
readonly_fields = ["created", "sender", "type", "content"]
|
|
44
|
+
can_delete = False
|
|
45
|
+
show_change_link = True
|
|
46
|
+
|
|
47
|
+
def has_add_permission(self, request, obj=None):
|
|
48
|
+
return False
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class ConversationAdmin(admin.ModelAdmin):
|
|
52
|
+
list_display = ["__str__", "channel", "state", "updated"]
|
|
53
|
+
list_filter = ["channel", "state"]
|
|
54
|
+
search_fields = ["title"]
|
|
55
|
+
inlines = [MembershipInline, RoomEventInline]
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def register_models(site: admin.AdminSite) -> None:
|
|
59
|
+
"""This package's models, unless a site swapped them for its own."""
|
|
60
|
+
if not Participant._meta.swapped:
|
|
61
|
+
site.register(Participant, ParticipantAdmin)
|
|
62
|
+
if not Conversation._meta.swapped:
|
|
63
|
+
site.register(Conversation, ConversationAdmin)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
register_models(admin.site)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@admin.register(RoomEvent)
|
|
70
|
+
class RoomEventAdmin(admin.ModelAdmin):
|
|
71
|
+
list_display = ["__str__", "type", "conversation", "sender", "created"]
|
|
72
|
+
list_filter = ["type", "conversation__channel"]
|
|
73
|
+
raw_id_fields = ["conversation", "sender", "relates_to", "redacted_by"]
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@admin.register(Media)
|
|
77
|
+
class MediaAdmin(admin.ModelAdmin):
|
|
78
|
+
list_display = ["name", "conversation", "mime", "size", "created"]
|
|
79
|
+
raw_id_fields = ["conversation", "uploader"]
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@admin.register(StreamEntry)
|
|
83
|
+
class StreamEntryAdmin(admin.ModelAdmin):
|
|
84
|
+
list_display = ["__str__", "kind", "type", "conversation", "recipient", "created"]
|
|
85
|
+
list_filter = ["kind"]
|
|
86
|
+
raw_id_fields = ["conversation", "recipient", "event"]
|
django_chat_ui/agui.py
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
"""An AI agent of any framework that speaks AG-UI (https://docs.ag-ui.com:
|
|
2
|
+
LangGraph, CrewAI, Mastra, Pydantic AI, Agno…) answering in a
|
|
3
|
+
conversation, as it writes.
|
|
4
|
+
|
|
5
|
+
from django_chat_ui.agui import AgUiAgent
|
|
6
|
+
|
|
7
|
+
agent = AgUiAgent("https://agents.example.com/shop")
|
|
8
|
+
|
|
9
|
+
class Backend(ChatBackend):
|
|
10
|
+
hooks = "thread"
|
|
11
|
+
|
|
12
|
+
def on_message(self, request, message):
|
|
13
|
+
if message.sender and message.sender.kind != "bot":
|
|
14
|
+
agent.answer(message.conversation, bot)
|
|
15
|
+
|
|
16
|
+
The conversation goes to the agent as a run (``RunAgentInput``: its
|
|
17
|
+
messages, ``threadId`` the room's ID); each text message it streams back
|
|
18
|
+
(``TEXT_MESSAGE_START`` / ``_CONTENT`` / ``_END``, or ``_CHUNK``) is a
|
|
19
|
+
message of ``bot``, through ``services.stream_text()``. Other events reach
|
|
20
|
+
``handle()``, to override (tool calls, state…).
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import itertools
|
|
26
|
+
import json
|
|
27
|
+
import urllib.request
|
|
28
|
+
import uuid
|
|
29
|
+
from typing import TYPE_CHECKING, Any
|
|
30
|
+
|
|
31
|
+
from . import services
|
|
32
|
+
from .matrix import room_id
|
|
33
|
+
|
|
34
|
+
if TYPE_CHECKING:
|
|
35
|
+
from collections.abc import Callable, Iterable, Iterator
|
|
36
|
+
|
|
37
|
+
from .models import AbstractConversation, AbstractParticipant, RoomEvent
|
|
38
|
+
|
|
39
|
+
TEXT = {"TEXT_MESSAGE_CONTENT", "TEXT_MESSAGE_CHUNK"}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class AgUiError(Exception):
|
|
43
|
+
"""The agent's ``RUN_ERROR``."""
|
|
44
|
+
|
|
45
|
+
def __init__(self, message: str, code: str | None = None):
|
|
46
|
+
super().__init__(message)
|
|
47
|
+
self.code = code
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def sse(lines: Iterable[bytes]) -> Iterator[dict[str, Any]]:
|
|
51
|
+
"""The events of a Server-Sent Events stream, as JSON objects."""
|
|
52
|
+
data: list[str] = []
|
|
53
|
+
for raw in lines:
|
|
54
|
+
line = raw.decode("utf-8").rstrip("\r\n")
|
|
55
|
+
if line.startswith("data:"):
|
|
56
|
+
data.append(line[5:].removeprefix(" "))
|
|
57
|
+
elif not line and data:
|
|
58
|
+
try:
|
|
59
|
+
event = json.loads("\n".join(data))
|
|
60
|
+
except ValueError:
|
|
61
|
+
event = None
|
|
62
|
+
data = []
|
|
63
|
+
if isinstance(event, dict):
|
|
64
|
+
yield event
|
|
65
|
+
if data:
|
|
66
|
+
try:
|
|
67
|
+
event = json.loads("\n".join(data))
|
|
68
|
+
except ValueError:
|
|
69
|
+
return
|
|
70
|
+
if isinstance(event, dict):
|
|
71
|
+
yield event
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class AgUiAgent:
|
|
75
|
+
"""An AG-UI agent at ``url`` (``headers``: its authentication)."""
|
|
76
|
+
|
|
77
|
+
def __init__(
|
|
78
|
+
self,
|
|
79
|
+
url: str,
|
|
80
|
+
*,
|
|
81
|
+
headers: dict[str, str] | None = None,
|
|
82
|
+
timeout: float = 120,
|
|
83
|
+
opener: Callable[..., Any] = urllib.request.urlopen,
|
|
84
|
+
):
|
|
85
|
+
self.url = url
|
|
86
|
+
self.headers = headers or {}
|
|
87
|
+
self.timeout = timeout
|
|
88
|
+
self.opener = opener
|
|
89
|
+
|
|
90
|
+
def messages(
|
|
91
|
+
self, conversation: AbstractConversation, bot: AbstractParticipant
|
|
92
|
+
) -> list[dict[str, Any]]:
|
|
93
|
+
"""The conversation as the agent takes it: the bot's messages are
|
|
94
|
+
the assistant's; anyone else's, the user's (system notices left out).
|
|
95
|
+
Edited messages, as they are now."""
|
|
96
|
+
edits = {
|
|
97
|
+
edit.relates_to_id: edit.content.get("m.new_content") or {}
|
|
98
|
+
for edit in conversation.messages.filter(rel_type="m.replace")
|
|
99
|
+
}
|
|
100
|
+
result = []
|
|
101
|
+
for message in conversation.messages.exclude(rel_type="m.replace").filter(
|
|
102
|
+
redacted_by=None
|
|
103
|
+
):
|
|
104
|
+
if message.sender_id is None:
|
|
105
|
+
continue
|
|
106
|
+
body = str(edits.get(message.pk, message.content).get("body", ""))
|
|
107
|
+
if not body:
|
|
108
|
+
continue
|
|
109
|
+
role = "assistant" if message.sender_id == bot.pk else "user"
|
|
110
|
+
result.append({"id": message.event_id, "role": role, "content": body})
|
|
111
|
+
return result
|
|
112
|
+
|
|
113
|
+
def run_input(
|
|
114
|
+
self, conversation: AbstractConversation, bot: AbstractParticipant
|
|
115
|
+
) -> dict[str, Any]:
|
|
116
|
+
"""``RunAgentInput``: override it to give the agent tools, context or
|
|
117
|
+
state."""
|
|
118
|
+
return {
|
|
119
|
+
"threadId": room_id(conversation),
|
|
120
|
+
"runId": uuid.uuid4().hex,
|
|
121
|
+
"state": {},
|
|
122
|
+
"messages": self.messages(conversation, bot),
|
|
123
|
+
"tools": [],
|
|
124
|
+
"context": [],
|
|
125
|
+
"forwardedProps": {},
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
def events(self, payload: dict[str, Any]) -> Iterator[dict[str, Any]]:
|
|
129
|
+
"""Run the agent: its events, as they come."""
|
|
130
|
+
request = urllib.request.Request(
|
|
131
|
+
self.url,
|
|
132
|
+
data=json.dumps(payload).encode(),
|
|
133
|
+
headers={
|
|
134
|
+
"Content-Type": "application/json",
|
|
135
|
+
"Accept": "text/event-stream",
|
|
136
|
+
**self.headers,
|
|
137
|
+
},
|
|
138
|
+
method="POST",
|
|
139
|
+
)
|
|
140
|
+
with self.opener(request, timeout=self.timeout) as response:
|
|
141
|
+
yield from sse(response)
|
|
142
|
+
|
|
143
|
+
def handle(self, event: dict[str, Any], conversation: AbstractConversation) -> None:
|
|
144
|
+
"""An event that isn't text: ``RUN_STARTED``, tool calls, state…"""
|
|
145
|
+
|
|
146
|
+
def answer(
|
|
147
|
+
self, conversation: AbstractConversation, bot: AbstractParticipant
|
|
148
|
+
) -> list[RoomEvent]:
|
|
149
|
+
"""Run the agent on the conversation; its text messages, sent as
|
|
150
|
+
``bot`` as they're written. A ``RUN_ERROR`` raises ``AgUiError``,
|
|
151
|
+
after sending what came."""
|
|
152
|
+
error: list[AgUiError] = []
|
|
153
|
+
|
|
154
|
+
def texts() -> Iterator[tuple[str, str]]:
|
|
155
|
+
current = ""
|
|
156
|
+
for event in self.events(self.run_input(conversation, bot)):
|
|
157
|
+
kind = event.get("type")
|
|
158
|
+
if kind == "TEXT_MESSAGE_START":
|
|
159
|
+
current = str(event.get("messageId") or current)
|
|
160
|
+
elif kind in TEXT and isinstance(event.get("delta"), str):
|
|
161
|
+
current = str(event.get("messageId") or current)
|
|
162
|
+
yield current, event["delta"]
|
|
163
|
+
elif kind == "RUN_ERROR":
|
|
164
|
+
error.append(
|
|
165
|
+
AgUiError(str(event.get("message", "")), event.get("code"))
|
|
166
|
+
)
|
|
167
|
+
return
|
|
168
|
+
elif kind not in {"TEXT_MESSAGE_END"}:
|
|
169
|
+
self.handle(event, conversation)
|
|
170
|
+
|
|
171
|
+
sent = []
|
|
172
|
+
for _message, chunks in itertools.groupby(texts(), key=lambda item: item[0]):
|
|
173
|
+
message = services.stream_text(
|
|
174
|
+
conversation, bot, (delta for _id, delta in chunks)
|
|
175
|
+
)
|
|
176
|
+
if message is not None:
|
|
177
|
+
sent.append(message)
|
|
178
|
+
if error:
|
|
179
|
+
raise error[0]
|
|
180
|
+
return sent
|
django_chat_ui/apps.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
from django.apps import AppConfig
|
|
2
|
+
from django.utils.translation import gettext_lazy as _
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class ChatUiConfig(AppConfig):
|
|
6
|
+
name = "django_chat_ui"
|
|
7
|
+
verbose_name = _("Chat")
|
|
8
|
+
default_auto_field = "django.db.models.BigAutoField"
|
|
9
|
+
|
|
10
|
+
def ready(self) -> None:
|
|
11
|
+
from django.conf import settings
|
|
12
|
+
|
|
13
|
+
from .models import DEFAULTS
|
|
14
|
+
|
|
15
|
+
# Always there, as AUTH_USER_MODEL is: migrations (this package's,
|
|
16
|
+
# and a site's pointing to its models) name them.
|
|
17
|
+
for name, default in DEFAULTS.items():
|
|
18
|
+
if not hasattr(settings, name):
|
|
19
|
+
setattr(settings, name, default)
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
"""What a site decides: who is chatting, what they see, what conversations
|
|
2
|
+
they can start and what happens when someone writes. Subclass
|
|
3
|
+
``ChatBackend`` and name it in the settings:
|
|
4
|
+
|
|
5
|
+
CHAT_UI = {"BACKEND": "myapp.chat.Backend"}
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import logging
|
|
11
|
+
import threading
|
|
12
|
+
from functools import cache
|
|
13
|
+
from typing import TYPE_CHECKING, Any
|
|
14
|
+
|
|
15
|
+
from django.conf import settings
|
|
16
|
+
from django.contrib.auth import authenticate, get_user_model
|
|
17
|
+
from django.db import connections, transaction
|
|
18
|
+
from django.utils.module_loading import import_string
|
|
19
|
+
from django.utils.translation import gettext_lazy as _
|
|
20
|
+
|
|
21
|
+
from . import services
|
|
22
|
+
from .models import (
|
|
23
|
+
AbstractConversation,
|
|
24
|
+
AbstractParticipant,
|
|
25
|
+
RoomEvent,
|
|
26
|
+
get_conversation_model,
|
|
27
|
+
get_participant_model,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
if TYPE_CHECKING:
|
|
31
|
+
from collections.abc import Callable
|
|
32
|
+
|
|
33
|
+
from django.db.models import QuerySet
|
|
34
|
+
from django.http import HttpRequest
|
|
35
|
+
|
|
36
|
+
SESSION_KEY = "django_chat_ui_participant"
|
|
37
|
+
|
|
38
|
+
logger = logging.getLogger("django_chat_ui")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class ChatBackend:
|
|
42
|
+
#: Whether visitors who aren't logged in can chat (the "contact us"
|
|
43
|
+
#: widget of a public site). Each gets a participant kept in their
|
|
44
|
+
#: session.
|
|
45
|
+
allow_anonymous = False
|
|
46
|
+
|
|
47
|
+
#: Where the hooks that react (``on_start``, ``on_message``,
|
|
48
|
+
#: ``on_action``, ``on_event``) run: ``"inline"``, inside the request
|
|
49
|
+
#: that caused them, or ``"thread"``, in a thread of their own once its
|
|
50
|
+
#: changes are committed, so it's answered at once (a bot that takes
|
|
51
|
+
#: seconds doesn't hold the sender's message). For a task queue,
|
|
52
|
+
#: override ``run_hook()``.
|
|
53
|
+
hooks = "inline"
|
|
54
|
+
|
|
55
|
+
#: The types of events of extensions that clients may send, besides
|
|
56
|
+
#: messages and reactions: namespaced (``com.acme.order.cancel``). Each
|
|
57
|
+
#: sent reaches ``on_event()``, after ``validate_content()``.
|
|
58
|
+
event_types: frozenset[str] | set[str] = frozenset()
|
|
59
|
+
|
|
60
|
+
#: Largest upload, in bytes, when a conversation's ``attachments`` don't
|
|
61
|
+
#: say.
|
|
62
|
+
max_upload_size = 5 * 1024 * 1024
|
|
63
|
+
|
|
64
|
+
# Who -------------------------------------------------------------------
|
|
65
|
+
|
|
66
|
+
def get_participant(self, request: HttpRequest) -> AbstractParticipant | None:
|
|
67
|
+
"""The participant of this request, or None (403). Users get one on
|
|
68
|
+
their first request; staff are agents."""
|
|
69
|
+
user = request.user
|
|
70
|
+
if user.is_authenticated:
|
|
71
|
+
return self.participant_for_user(user)
|
|
72
|
+
if not self.allow_anonymous:
|
|
73
|
+
return None
|
|
74
|
+
pk = request.session.get(SESSION_KEY)
|
|
75
|
+
participant = get_participant_model().objects.filter(pk=pk, user=None).first()
|
|
76
|
+
if participant is None:
|
|
77
|
+
participant = get_participant_model().objects.create(
|
|
78
|
+
kind="contact", name=str(_("Visitor"))
|
|
79
|
+
)
|
|
80
|
+
request.session[SESSION_KEY] = participant.pk
|
|
81
|
+
return participant
|
|
82
|
+
|
|
83
|
+
def login(
|
|
84
|
+
self, request: HttpRequest, username: str, password: str
|
|
85
|
+
) -> AbstractParticipant | None:
|
|
86
|
+
"""The participant of a Matrix client logging in with a password
|
|
87
|
+
(``POST /login``): a user of the site's, with theirs."""
|
|
88
|
+
user = authenticate(request, username=username, password=password)
|
|
89
|
+
if user is None or not user.is_active:
|
|
90
|
+
return None
|
|
91
|
+
return self.participant_for_user(user)
|
|
92
|
+
|
|
93
|
+
def participant_for_user(self, user: Any) -> AbstractParticipant:
|
|
94
|
+
participant, _ = get_participant_model().objects.get_or_create(
|
|
95
|
+
user=user,
|
|
96
|
+
defaults={
|
|
97
|
+
"kind": "agent" if user.is_staff else "user",
|
|
98
|
+
"name": user.get_full_name() or user.get_username(),
|
|
99
|
+
},
|
|
100
|
+
)
|
|
101
|
+
return participant
|
|
102
|
+
|
|
103
|
+
# What they see ---------------------------------------------------------
|
|
104
|
+
|
|
105
|
+
def conversations(
|
|
106
|
+
self, participant: AbstractParticipant
|
|
107
|
+
) -> QuerySet[AbstractConversation]:
|
|
108
|
+
"""The conversations ``participant`` sees: those they take part in.
|
|
109
|
+
The stream only carries events of these."""
|
|
110
|
+
return get_conversation_model().objects.filter(
|
|
111
|
+
memberships__participant=participant
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
def serialize_participant(
|
|
115
|
+
self, participant: AbstractParticipant, data: dict[str, Any]
|
|
116
|
+
) -> dict[str, Any]:
|
|
117
|
+
"""This package's block of a participant's ``m.room.member`` (its
|
|
118
|
+
``kind``, ``handle``, ``avatar`` and ``ext``): add what a site's
|
|
119
|
+
model has, under ``ext`` with your namespace (``{"com.acme": …}``)."""
|
|
120
|
+
return data
|
|
121
|
+
|
|
122
|
+
def serialize_conversation(
|
|
123
|
+
self, conversation: AbstractConversation, data: dict[str, Any]
|
|
124
|
+
) -> dict[str, Any]:
|
|
125
|
+
"""This package's state event of what a conversation is (its
|
|
126
|
+
``channel``, ``state`` and ``ext``): add what a site's model has (its
|
|
127
|
+
priority, who it's assigned to…), under ``ext``."""
|
|
128
|
+
return data
|
|
129
|
+
|
|
130
|
+
# Starting conversations ------------------------------------------------
|
|
131
|
+
|
|
132
|
+
def can_start(
|
|
133
|
+
self,
|
|
134
|
+
participant: AbstractParticipant,
|
|
135
|
+
others: list[AbstractParticipant],
|
|
136
|
+
channel: str,
|
|
137
|
+
) -> bool:
|
|
138
|
+
"""Whether ``participant`` may start a conversation with ``others``.
|
|
139
|
+
Agents, with anyone; everybody else, only with the support team
|
|
140
|
+
(``others`` empty)."""
|
|
141
|
+
return participant.kind == "agent" or not others
|
|
142
|
+
|
|
143
|
+
def start(
|
|
144
|
+
self,
|
|
145
|
+
request: HttpRequest,
|
|
146
|
+
participant: AbstractParticipant,
|
|
147
|
+
others: list[AbstractParticipant],
|
|
148
|
+
channel: str,
|
|
149
|
+
) -> AbstractConversation:
|
|
150
|
+
"""A new conversation (``POST /createRoom``). Without ``others`` (the
|
|
151
|
+
"contact us" widget) it goes to ``support_team()`` and waits,
|
|
152
|
+
``pending``, for one of them to answer."""
|
|
153
|
+
team = [] if others else self.support_team(channel)
|
|
154
|
+
conversation = services.create_conversation(
|
|
155
|
+
[participant, *others, *team],
|
|
156
|
+
channel=channel,
|
|
157
|
+
# The team's inbox names it after whoever wrote, not the team.
|
|
158
|
+
title="" if others else participant.name,
|
|
159
|
+
state="open" if others else "pending",
|
|
160
|
+
capabilities=self.new_capabilities(channel),
|
|
161
|
+
creator=participant,
|
|
162
|
+
)
|
|
163
|
+
self.run_hook(self.on_start, request, conversation, participant)
|
|
164
|
+
return conversation
|
|
165
|
+
|
|
166
|
+
def support_team(self, channel: str) -> list[AbstractParticipant]:
|
|
167
|
+
"""Who receives conversations started without anyone: every active
|
|
168
|
+
staff user. Override it to route them (a queue, a bot first…)."""
|
|
169
|
+
users = get_user_model().objects.filter(is_staff=True, is_active=True)
|
|
170
|
+
return [self.participant_for_user(user) for user in users]
|
|
171
|
+
|
|
172
|
+
def new_capabilities(self, channel: str) -> dict[str, Any]:
|
|
173
|
+
"""The capabilities of the conversations it starts (section 6.1)."""
|
|
174
|
+
return {
|
|
175
|
+
"typing": True,
|
|
176
|
+
"receipts": True,
|
|
177
|
+
"history": True,
|
|
178
|
+
"replies": True,
|
|
179
|
+
"attachments": {
|
|
180
|
+
"types": ["image", "file"],
|
|
181
|
+
"maxSize": self.max_upload_size,
|
|
182
|
+
},
|
|
183
|
+
"composer": {"mode": "free", "maxLength": 4096},
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
# What happens ----------------------------------------------------------
|
|
187
|
+
|
|
188
|
+
def run_hook(self, hook: Callable[..., Any], *args: Any) -> None:
|
|
189
|
+
"""Run a hook that reacts to a command, as ``hooks`` says. Override
|
|
190
|
+
it to send them to a task queue (Celery, ``django.tasks``…)."""
|
|
191
|
+
if self.hooks == "thread":
|
|
192
|
+
transaction.on_commit(
|
|
193
|
+
lambda: threading.Thread(
|
|
194
|
+
target=self.run_hook_now, args=(hook, *args), daemon=True
|
|
195
|
+
).start()
|
|
196
|
+
)
|
|
197
|
+
else:
|
|
198
|
+
hook(*args)
|
|
199
|
+
|
|
200
|
+
def run_hook_now(self, hook: Callable[..., Any], *args: Any) -> None:
|
|
201
|
+
"""Run a hook away from its request: its failure is logged, and its
|
|
202
|
+
thread's database connection closed."""
|
|
203
|
+
try:
|
|
204
|
+
hook(*args)
|
|
205
|
+
except Exception:
|
|
206
|
+
logger.exception("django-chat-ui: %s failed", hook.__name__)
|
|
207
|
+
finally:
|
|
208
|
+
connections.close_all()
|
|
209
|
+
|
|
210
|
+
def before_send(
|
|
211
|
+
self,
|
|
212
|
+
request: HttpRequest,
|
|
213
|
+
conversation: AbstractConversation,
|
|
214
|
+
participant: AbstractParticipant,
|
|
215
|
+
event_type: str,
|
|
216
|
+
content: dict[str, Any],
|
|
217
|
+
) -> None:
|
|
218
|
+
"""Just before a client's event is added (checked against what the
|
|
219
|
+
conversation allows): what must come first in the thread ("Ana
|
|
220
|
+
joined"), or a last refusal (raise ``MatrixError``). Always inline."""
|
|
221
|
+
|
|
222
|
+
def on_start(
|
|
223
|
+
self,
|
|
224
|
+
request: HttpRequest,
|
|
225
|
+
conversation: AbstractConversation,
|
|
226
|
+
participant: AbstractParticipant,
|
|
227
|
+
) -> None:
|
|
228
|
+
"""After a conversation is started: a greeting, a bot…"""
|
|
229
|
+
|
|
230
|
+
def on_message(self, request: HttpRequest, message: RoomEvent) -> None:
|
|
231
|
+
"""After someone sent a message (not an edit, nor a button's
|
|
232
|
+
answer): a bot's answer, sending it on to WhatsApp… It runs as
|
|
233
|
+
``hooks`` says."""
|
|
234
|
+
|
|
235
|
+
def on_action(
|
|
236
|
+
self,
|
|
237
|
+
request: HttpRequest,
|
|
238
|
+
participant: AbstractParticipant,
|
|
239
|
+
message: RoomEvent,
|
|
240
|
+
button: dict[str, Any],
|
|
241
|
+
) -> None:
|
|
242
|
+
"""After someone used a button of ``message``: their answer (the
|
|
243
|
+
button's label, as WhatsApp writes it) is in the thread already."""
|
|
244
|
+
|
|
245
|
+
def on_event(self, request: HttpRequest, event: RoomEvent) -> None:
|
|
246
|
+
"""After a client sent an event of ``event_types``."""
|
|
247
|
+
|
|
248
|
+
def validate_content(
|
|
249
|
+
self,
|
|
250
|
+
conversation: AbstractConversation,
|
|
251
|
+
participant: AbstractParticipant,
|
|
252
|
+
event_type: str,
|
|
253
|
+
content: dict[str, Any],
|
|
254
|
+
) -> None:
|
|
255
|
+
"""Raise ``MatrixError`` for an event ``participant`` can't send.
|
|
256
|
+
What the conversation allows is checked already; messages of
|
|
257
|
+
extensions' types (namespaced ``msgtype``, with a ``body``) and
|
|
258
|
+
events of ``event_types`` get here, allowed."""
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
@cache
|
|
262
|
+
def _backend_class(path: str) -> type[ChatBackend]:
|
|
263
|
+
return import_string(path)
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def get_backend() -> ChatBackend:
|
|
267
|
+
config = getattr(settings, "CHAT_UI", {})
|
|
268
|
+
path = config.get("BACKEND", "django_chat_ui.backend.ChatBackend")
|
|
269
|
+
return _backend_class(path)()
|