mirror of
https://github.com/dartdavros/chatballs.git
synced 2026-10-05 17:14:59 +03:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
54c35e281a | ||
|
|
5396dda8ad | ||
|
|
29164db094 | ||
|
|
c5a53ccec5 | ||
|
|
049217505a | ||
|
|
132c684630 | ||
|
|
bfe90e9abd | ||
|
|
5b49904d6a | ||
|
|
cd50cce5a8 | ||
|
|
a4ca7e9373 | ||
|
|
145c44a17d | ||
|
|
ac824e192c | ||
|
|
3e790af6e5 | ||
|
|
1390ccc426 | ||
|
|
2e52bdaea0 | ||
|
|
5bf3d9b572 | ||
|
|
6d94aa1f9f | ||
|
|
0e6a3db9a6 | ||
|
|
d7f87719de | ||
|
|
6c3dec7339 | ||
|
|
f91b3da27a |
No files matched your search
@@ -23,9 +23,9 @@ jobs:
|
||||
name: ruff
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- uses: actions/setup-python@v5
|
||||
- uses: actions/setup-python@v7
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
@@ -46,9 +46,9 @@ jobs:
|
||||
name: deployment CLI
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- uses: actions/setup-python@v5
|
||||
- uses: actions/setup-python@v7
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
|
||||
@@ -39,12 +39,13 @@ env:
|
||||
GATEWAY_BASE_IMAGE: caddy:2.8.4
|
||||
REDIS_IMAGE: redis:7-alpine
|
||||
COTURN_IMAGE: coturn/coturn:4.6
|
||||
UPDATER_BASE_IMAGE: docker:27-cli
|
||||
|
||||
jobs:
|
||||
images:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- name: Версия релиза
|
||||
id: version
|
||||
@@ -57,9 +58,9 @@ jobs:
|
||||
echo "value=$version" >> "$GITHUB_OUTPUT"
|
||||
echo "repo=$(echo '${{ github.repository }}' | tr '[:upper:]' '[:lower:]')" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- uses: docker/setup-buildx-action@v3
|
||||
- uses: docker/setup-buildx-action@v4
|
||||
|
||||
- uses: docker/login-action@v3
|
||||
- uses: docker/login-action@v4
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
@@ -78,13 +79,16 @@ jobs:
|
||||
echo "gateway=${GATEWAY_BASE_IMAGE}@$(digest_of "$GATEWAY_BASE_IMAGE")" >> "$GITHUB_OUTPUT"
|
||||
echo "redis=${REDIS_IMAGE}@$(digest_of "$REDIS_IMAGE")" >> "$GITHUB_OUTPUT"
|
||||
echo "coturn=${COTURN_IMAGE}@$(digest_of "$COTURN_IMAGE")" >> "$GITHUB_OUTPUT"
|
||||
echo "updater=${UPDATER_BASE_IMAGE}@$(digest_of "$UPDATER_BASE_IMAGE")" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Backend
|
||||
id: backend
|
||||
uses: docker/build-push-action@v6
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: ${{ env.APP_DIR }}
|
||||
file: ${{ env.APP_DIR }}/apps/backend/Dockerfile.production
|
||||
build-args: |
|
||||
CHATBALLS_VERSION=${{ steps.version.outputs.value }}
|
||||
push: true
|
||||
tags: |
|
||||
${{ env.REGISTRY }}/${{ steps.version.outputs.repo }}/backend:${{ steps.version.outputs.value }}
|
||||
@@ -94,7 +98,7 @@ jobs:
|
||||
|
||||
- name: Frontend
|
||||
id: frontend
|
||||
uses: docker/build-push-action@v6
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: ${{ env.APP_DIR }}
|
||||
file: ${{ env.APP_DIR }}/deploy/docker/frontend.Dockerfile
|
||||
@@ -109,7 +113,7 @@ jobs:
|
||||
# а не монтируются с хоста. Ради этого установка и стала одним файлом.
|
||||
- name: Gateway
|
||||
id: gateway
|
||||
uses: docker/build-push-action@v6
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: ${{ env.APP_DIR }}
|
||||
file: ${{ env.APP_DIR }}/deploy/docker/gateway.Dockerfile
|
||||
@@ -124,7 +128,7 @@ jobs:
|
||||
|
||||
- name: Postgres
|
||||
id: postgres
|
||||
uses: docker/build-push-action@v6
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: ${{ env.APP_DIR }}
|
||||
file: ${{ env.APP_DIR }}/deploy/docker/postgres.Dockerfile
|
||||
@@ -137,6 +141,23 @@ jobs:
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
# Сервис обновления по кнопке (ADR-CHATBALLS-0049): docker CLI с compose
|
||||
# и два сценария внутри; единственный, кому монтируется docker.sock.
|
||||
- name: Updater
|
||||
id: updater
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: ${{ env.APP_DIR }}
|
||||
file: ${{ env.APP_DIR }}/deploy/docker/updater.Dockerfile
|
||||
build-args: |
|
||||
CHATBALLS_UPDATER_BASE_IMAGE=${{ steps.bases.outputs.updater }}
|
||||
push: true
|
||||
tags: |
|
||||
${{ env.REGISTRY }}/${{ steps.version.outputs.repo }}/updater:${{ steps.version.outputs.value }}
|
||||
${{ env.REGISTRY }}/${{ steps.version.outputs.repo }}/updater:latest
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
- name: Релизный compose.yaml и release.env
|
||||
id: artifacts
|
||||
run: |
|
||||
@@ -148,6 +169,7 @@ jobs:
|
||||
frontend="$prefix/frontend:$version@${{ steps.frontend.outputs.digest }}"
|
||||
gateway="$prefix/gateway:$version@${{ steps.gateway.outputs.digest }}"
|
||||
postgres="$prefix/postgres:$version@${{ steps.postgres.outputs.digest }}"
|
||||
updater="$prefix/updater:$version@${{ steps.updater.outputs.digest }}"
|
||||
redis="${{ steps.bases.outputs.redis }}"
|
||||
coturn="${{ steps.bases.outputs.coturn }}"
|
||||
|
||||
@@ -160,7 +182,8 @@ jobs:
|
||||
--pin "CHATBALLS_GATEWAY_IMAGE=$gateway" \
|
||||
--pin "CHATBALLS_POSTGRES_IMAGE=$postgres" \
|
||||
--pin "CHATBALLS_REDIS_IMAGE=$redis" \
|
||||
--pin "CHATBALLS_COTURN_IMAGE=$coturn"
|
||||
--pin "CHATBALLS_COTURN_IMAGE=$coturn" \
|
||||
--pin "CHATBALLS_UPDATER_IMAGE=$updater"
|
||||
|
||||
{
|
||||
echo "# release.env — digest-пины релиза $version для \`chatballs deploy\`."
|
||||
@@ -172,6 +195,7 @@ jobs:
|
||||
echo "CHATBALLS_POSTGRES_IMAGE=$postgres"
|
||||
echo "CHATBALLS_REDIS_IMAGE=$redis"
|
||||
echo "CHATBALLS_COTURN_IMAGE=$coturn"
|
||||
echo "CHATBALLS_UPDATER_IMAGE=$updater"
|
||||
} > dist/release.env
|
||||
|
||||
# Файл, который скачает человек, обязан быть валидным сам по себе —
|
||||
@@ -180,7 +204,7 @@ jobs:
|
||||
|
||||
cat dist/compose.yaml
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
- uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: release-compose
|
||||
path: |
|
||||
@@ -189,7 +213,7 @@ jobs:
|
||||
|
||||
- name: Приложить к релизу
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
uses: softprops/action-gh-release@v2
|
||||
uses: softprops/action-gh-release@v3
|
||||
with:
|
||||
files: |
|
||||
dist/compose.yaml
|
||||
|
||||
+4
-1
@@ -20,7 +20,10 @@ test-results/
|
||||
.DS_Store
|
||||
|
||||
# Перенесено из корневого .gitignore при переносе корня репозитория в code/chatballs
|
||||
data/
|
||||
# Тома docker-стенда (postgres, redis, caddy, media) — только в корне репозитория.
|
||||
# Без якоря правило ловило и demo_seed/data: русские манифесты в индексе уже
|
||||
# лежали, а новые файлы туда молча не попадали.
|
||||
/data/
|
||||
.env.bak-*
|
||||
**/staticfiles/
|
||||
|
||||
|
||||
@@ -44,6 +44,17 @@ Engineering rules / обязательные практики:
|
||||
- Не инлайнить сложный UI повторно. Выносить компонент, если структура повторяется или секция имеет отдельную ответственность.
|
||||
- Если реализация по задаче конфликтует с этими правилами, остановиться и явно сообщить blocker вместо поставки плохой структуры.
|
||||
|
||||
Язык интерфейса:
|
||||
- Текста, который видит человек, в коде не бывает. Строка живёт в словаре и подставляется через `t(...)`: во фронтенде это `src/i18n` приложения (`packages/ui` — свой словарь), на бэкенде `chatballs/i18n`. Русский каталог — источник ключей, английский обязан его повторить: пропущенный перевод во фронтенде ловит `tsc`, на бэкенде — тест каталога.
|
||||
- Ключ пишется от смысла, а не от фразы: `settings.storage_bucket_name`, а не пересказ текста. Строку переформулируют чаще, чем переименовывают.
|
||||
- Число не склоняют вручную: формы задаёт словарь, форму выбирает `tn(...)`. Дату, размер файла и разряды числа форматирует `fmt` — прибитые `"ru-RU"` и свои списки месяцев запрещены.
|
||||
- Что записано в базу, переводится по коду, а не текстом: системные события диалога, уведомления и подписи журнала аудита хранят код, а фразу собирает бэкенд на языке читателя. Писать в историю готовую русскую фразу нельзя — её потом не перевести.
|
||||
- Язык запроса выбирается цепочкой: профиль сотрудника → организация → установка → браузер. Письмо получает язык адресата, а не отправителя.
|
||||
- На бэкенде `t(...)` не вызывается на уровне модуля: язык там свой на каждый запрос, а константа посчиталась бы один раз при импорте.
|
||||
- Текст, уходящий наружу — клиенту в мессенджер, письмо, кнопку виджета, — берёт язык не запроса, а организации: `t(..., language=customer_language(organization))`. Язык оператора, нажавшего кнопку, к речи компании с её клиентом отношения не имеет, и половина такого текста вообще рождается в воркере, где запроса нет.
|
||||
- Системный промпт агента не переводится: его читает модель. Язык ответа задаёт отдельная директива по полю `AIAgent.answer_language` — по умолчанию «как у клиента».
|
||||
- Демо-набор ставится на языке организации: манифесты лежат в `demo_seed/data/<язык>/`, наборы ключей в них совпадают. Бинарные вложения (аватары, голосовые) общие, текстовые документы — свои на каждый язык.
|
||||
|
||||
Definition of Done:
|
||||
- Запускать только тесты, относящиеся к изменениям текущей итерации. Полный прогон всех тестов выполнять только по явному указанию владельца.
|
||||
- Измененные файлы должны быть проверены на NO GOD violations.
|
||||
|
||||
@@ -0,0 +1,661 @@
|
||||
GNU AFFERO GENERAL PUBLIC LICENSE
|
||||
Version 3, 19 November 2007
|
||||
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
Preamble
|
||||
|
||||
The GNU Affero General Public License is a free, copyleft license for
|
||||
software and other kinds of works, specifically designed to ensure
|
||||
cooperation with the community in the case of network server software.
|
||||
|
||||
The licenses for most software and other practical works are designed
|
||||
to take away your freedom to share and change the works. By contrast,
|
||||
our General Public Licenses are intended to guarantee your freedom to
|
||||
share and change all versions of a program--to make sure it remains free
|
||||
software for all its users.
|
||||
|
||||
When we speak of free software, we are referring to freedom, not
|
||||
price. Our General Public Licenses are designed to make sure that you
|
||||
have the freedom to distribute copies of free software (and charge for
|
||||
them if you wish), that you receive source code or can get it if you
|
||||
want it, that you can change the software or use pieces of it in new
|
||||
free programs, and that you know you can do these things.
|
||||
|
||||
Developers that use our General Public Licenses protect your rights
|
||||
with two steps: (1) assert copyright on the software, and (2) offer
|
||||
you this License which gives you legal permission to copy, distribute
|
||||
and/or modify the software.
|
||||
|
||||
A secondary benefit of defending all users' freedom is that
|
||||
improvements made in alternate versions of the program, if they
|
||||
receive widespread use, become available for other developers to
|
||||
incorporate. Many developers of free software are heartened and
|
||||
encouraged by the resulting cooperation. However, in the case of
|
||||
software used on network servers, this result may fail to come about.
|
||||
The GNU General Public License permits making a modified version and
|
||||
letting the public access it on a server without ever releasing its
|
||||
source code to the public.
|
||||
|
||||
The GNU Affero General Public License is designed specifically to
|
||||
ensure that, in such cases, the modified source code becomes available
|
||||
to the community. It requires the operator of a network server to
|
||||
provide the source code of the modified version running there to the
|
||||
users of that server. Therefore, public use of a modified version, on
|
||||
a publicly accessible server, gives the public access to the source
|
||||
code of the modified version.
|
||||
|
||||
An older license, called the Affero General Public License and
|
||||
published by Affero, was designed to accomplish similar goals. This is
|
||||
a different license, not a version of the Affero GPL, but Affero has
|
||||
released a new version of the Affero GPL which permits relicensing under
|
||||
this license.
|
||||
|
||||
The precise terms and conditions for copying, distribution and
|
||||
modification follow.
|
||||
|
||||
TERMS AND CONDITIONS
|
||||
|
||||
0. Definitions.
|
||||
|
||||
"This License" refers to version 3 of the GNU Affero General Public License.
|
||||
|
||||
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||
works, such as semiconductor masks.
|
||||
|
||||
"The Program" refers to any copyrightable work licensed under this
|
||||
License. Each licensee is addressed as "you". "Licensees" and
|
||||
"recipients" may be individuals or organizations.
|
||||
|
||||
To "modify" a work means to copy from or adapt all or part of the work
|
||||
in a fashion requiring copyright permission, other than the making of an
|
||||
exact copy. The resulting work is called a "modified version" of the
|
||||
earlier work or a work "based on" the earlier work.
|
||||
|
||||
A "covered work" means either the unmodified Program or a work based
|
||||
on the Program.
|
||||
|
||||
To "propagate" a work means to do anything with it that, without
|
||||
permission, would make you directly or secondarily liable for
|
||||
infringement under applicable copyright law, except executing it on a
|
||||
computer or modifying a private copy. Propagation includes copying,
|
||||
distribution (with or without modification), making available to the
|
||||
public, and in some countries other activities as well.
|
||||
|
||||
To "convey" a work means any kind of propagation that enables other
|
||||
parties to make or receive copies. Mere interaction with a user through
|
||||
a computer network, with no transfer of a copy, is not conveying.
|
||||
|
||||
An interactive user interface displays "Appropriate Legal Notices"
|
||||
to the extent that it includes a convenient and prominently visible
|
||||
feature that (1) displays an appropriate copyright notice, and (2)
|
||||
tells the user that there is no warranty for the work (except to the
|
||||
extent that warranties are provided), that licensees may convey the
|
||||
work under this License, and how to view a copy of this License. If
|
||||
the interface presents a list of user commands or options, such as a
|
||||
menu, a prominent item in the list meets this criterion.
|
||||
|
||||
1. Source Code.
|
||||
|
||||
The "source code" for a work means the preferred form of the work
|
||||
for making modifications to it. "Object code" means any non-source
|
||||
form of a work.
|
||||
|
||||
A "Standard Interface" means an interface that either is an official
|
||||
standard defined by a recognized standards body, or, in the case of
|
||||
interfaces specified for a particular programming language, one that
|
||||
is widely used among developers working in that language.
|
||||
|
||||
The "System Libraries" of an executable work include anything, other
|
||||
than the work as a whole, that (a) is included in the normal form of
|
||||
packaging a Major Component, but which is not part of that Major
|
||||
Component, and (b) serves only to enable use of the work with that
|
||||
Major Component, or to implement a Standard Interface for which an
|
||||
implementation is available to the public in source code form. A
|
||||
"Major Component", in this context, means a major essential component
|
||||
(kernel, window system, and so on) of the specific operating system
|
||||
(if any) on which the executable work runs, or a compiler used to
|
||||
produce the work, or an object code interpreter used to run it.
|
||||
|
||||
The "Corresponding Source" for a work in object code form means all
|
||||
the source code needed to generate, install, and (for an executable
|
||||
work) run the object code and to modify the work, including scripts to
|
||||
control those activities. However, it does not include the work's
|
||||
System Libraries, or general-purpose tools or generally available free
|
||||
programs which are used unmodified in performing those activities but
|
||||
which are not part of the work. For example, Corresponding Source
|
||||
includes interface definition files associated with source files for
|
||||
the work, and the source code for shared libraries and dynamically
|
||||
linked subprograms that the work is specifically designed to require,
|
||||
such as by intimate data communication or control flow between those
|
||||
subprograms and other parts of the work.
|
||||
|
||||
The Corresponding Source need not include anything that users
|
||||
can regenerate automatically from other parts of the Corresponding
|
||||
Source.
|
||||
|
||||
The Corresponding Source for a work in source code form is that
|
||||
same work.
|
||||
|
||||
2. Basic Permissions.
|
||||
|
||||
All rights granted under this License are granted for the term of
|
||||
copyright on the Program, and are irrevocable provided the stated
|
||||
conditions are met. This License explicitly affirms your unlimited
|
||||
permission to run the unmodified Program. The output from running a
|
||||
covered work is covered by this License only if the output, given its
|
||||
content, constitutes a covered work. This License acknowledges your
|
||||
rights of fair use or other equivalent, as provided by copyright law.
|
||||
|
||||
You may make, run and propagate covered works that you do not
|
||||
convey, without conditions so long as your license otherwise remains
|
||||
in force. You may convey covered works to others for the sole purpose
|
||||
of having them make modifications exclusively for you, or provide you
|
||||
with facilities for running those works, provided that you comply with
|
||||
the terms of this License in conveying all material for which you do
|
||||
not control copyright. Those thus making or running the covered works
|
||||
for you must do so exclusively on your behalf, under your direction
|
||||
and control, on terms that prohibit them from making any copies of
|
||||
your copyrighted material outside their relationship with you.
|
||||
|
||||
Conveying under any other circumstances is permitted solely under
|
||||
the conditions stated below. Sublicensing is not allowed; section 10
|
||||
makes it unnecessary.
|
||||
|
||||
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||
|
||||
No covered work shall be deemed part of an effective technological
|
||||
measure under any applicable law fulfilling obligations under article
|
||||
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||
similar laws prohibiting or restricting circumvention of such
|
||||
measures.
|
||||
|
||||
When you convey a covered work, you waive any legal power to forbid
|
||||
circumvention of technological measures to the extent such circumvention
|
||||
is effected by exercising rights under this License with respect to
|
||||
the covered work, and you disclaim any intention to limit operation or
|
||||
modification of the work as a means of enforcing, against the work's
|
||||
users, your or third parties' legal rights to forbid circumvention of
|
||||
technological measures.
|
||||
|
||||
4. Conveying Verbatim Copies.
|
||||
|
||||
You may convey verbatim copies of the Program's source code as you
|
||||
receive it, in any medium, provided that you conspicuously and
|
||||
appropriately publish on each copy an appropriate copyright notice;
|
||||
keep intact all notices stating that this License and any
|
||||
non-permissive terms added in accord with section 7 apply to the code;
|
||||
keep intact all notices of the absence of any warranty; and give all
|
||||
recipients a copy of this License along with the Program.
|
||||
|
||||
You may charge any price or no price for each copy that you convey,
|
||||
and you may offer support or warranty protection for a fee.
|
||||
|
||||
5. Conveying Modified Source Versions.
|
||||
|
||||
You may convey a work based on the Program, or the modifications to
|
||||
produce it from the Program, in the form of source code under the
|
||||
terms of section 4, provided that you also meet all of these conditions:
|
||||
|
||||
a) The work must carry prominent notices stating that you modified
|
||||
it, and giving a relevant date.
|
||||
|
||||
b) The work must carry prominent notices stating that it is
|
||||
released under this License and any conditions added under section
|
||||
7. This requirement modifies the requirement in section 4 to
|
||||
"keep intact all notices".
|
||||
|
||||
c) You must license the entire work, as a whole, under this
|
||||
License to anyone who comes into possession of a copy. This
|
||||
License will therefore apply, along with any applicable section 7
|
||||
additional terms, to the whole of the work, and all its parts,
|
||||
regardless of how they are packaged. This License gives no
|
||||
permission to license the work in any other way, but it does not
|
||||
invalidate such permission if you have separately received it.
|
||||
|
||||
d) If the work has interactive user interfaces, each must display
|
||||
Appropriate Legal Notices; however, if the Program has interactive
|
||||
interfaces that do not display Appropriate Legal Notices, your
|
||||
work need not make them do so.
|
||||
|
||||
A compilation of a covered work with other separate and independent
|
||||
works, which are not by their nature extensions of the covered work,
|
||||
and which are not combined with it such as to form a larger program,
|
||||
in or on a volume of a storage or distribution medium, is called an
|
||||
"aggregate" if the compilation and its resulting copyright are not
|
||||
used to limit the access or legal rights of the compilation's users
|
||||
beyond what the individual works permit. Inclusion of a covered work
|
||||
in an aggregate does not cause this License to apply to the other
|
||||
parts of the aggregate.
|
||||
|
||||
6. Conveying Non-Source Forms.
|
||||
|
||||
You may convey a covered work in object code form under the terms
|
||||
of sections 4 and 5, provided that you also convey the
|
||||
machine-readable Corresponding Source under the terms of this License,
|
||||
in one of these ways:
|
||||
|
||||
a) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by the
|
||||
Corresponding Source fixed on a durable physical medium
|
||||
customarily used for software interchange.
|
||||
|
||||
b) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by a
|
||||
written offer, valid for at least three years and valid for as
|
||||
long as you offer spare parts or customer support for that product
|
||||
model, to give anyone who possesses the object code either (1) a
|
||||
copy of the Corresponding Source for all the software in the
|
||||
product that is covered by this License, on a durable physical
|
||||
medium customarily used for software interchange, for a price no
|
||||
more than your reasonable cost of physically performing this
|
||||
conveying of source, or (2) access to copy the
|
||||
Corresponding Source from a network server at no charge.
|
||||
|
||||
c) Convey individual copies of the object code with a copy of the
|
||||
written offer to provide the Corresponding Source. This
|
||||
alternative is allowed only occasionally and noncommercially, and
|
||||
only if you received the object code with such an offer, in accord
|
||||
with subsection 6b.
|
||||
|
||||
d) Convey the object code by offering access from a designated
|
||||
place (gratis or for a charge), and offer equivalent access to the
|
||||
Corresponding Source in the same way through the same place at no
|
||||
further charge. You need not require recipients to copy the
|
||||
Corresponding Source along with the object code. If the place to
|
||||
copy the object code is a network server, the Corresponding Source
|
||||
may be on a different server (operated by you or a third party)
|
||||
that supports equivalent copying facilities, provided you maintain
|
||||
clear directions next to the object code saying where to find the
|
||||
Corresponding Source. Regardless of what server hosts the
|
||||
Corresponding Source, you remain obligated to ensure that it is
|
||||
available for as long as needed to satisfy these requirements.
|
||||
|
||||
e) Convey the object code using peer-to-peer transmission, provided
|
||||
you inform other peers where the object code and Corresponding
|
||||
Source of the work are being offered to the general public at no
|
||||
charge under subsection 6d.
|
||||
|
||||
A separable portion of the object code, whose source code is excluded
|
||||
from the Corresponding Source as a System Library, need not be
|
||||
included in conveying the object code work.
|
||||
|
||||
A "User Product" is either (1) a "consumer product", which means any
|
||||
tangible personal property which is normally used for personal, family,
|
||||
or household purposes, or (2) anything designed or sold for incorporation
|
||||
into a dwelling. In determining whether a product is a consumer product,
|
||||
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||
product received by a particular user, "normally used" refers to a
|
||||
typical or common use of that class of product, regardless of the status
|
||||
of the particular user or of the way in which the particular user
|
||||
actually uses, or expects or is expected to use, the product. A product
|
||||
is a consumer product regardless of whether the product has substantial
|
||||
commercial, industrial or non-consumer uses, unless such uses represent
|
||||
the only significant mode of use of the product.
|
||||
|
||||
"Installation Information" for a User Product means any methods,
|
||||
procedures, authorization keys, or other information required to install
|
||||
and execute modified versions of a covered work in that User Product from
|
||||
a modified version of its Corresponding Source. The information must
|
||||
suffice to ensure that the continued functioning of the modified object
|
||||
code is in no case prevented or interfered with solely because
|
||||
modification has been made.
|
||||
|
||||
If you convey an object code work under this section in, or with, or
|
||||
specifically for use in, a User Product, and the conveying occurs as
|
||||
part of a transaction in which the right of possession and use of the
|
||||
User Product is transferred to the recipient in perpetuity or for a
|
||||
fixed term (regardless of how the transaction is characterized), the
|
||||
Corresponding Source conveyed under this section must be accompanied
|
||||
by the Installation Information. But this requirement does not apply
|
||||
if neither you nor any third party retains the ability to install
|
||||
modified object code on the User Product (for example, the work has
|
||||
been installed in ROM).
|
||||
|
||||
The requirement to provide Installation Information does not include a
|
||||
requirement to continue to provide support service, warranty, or updates
|
||||
for a work that has been modified or installed by the recipient, or for
|
||||
the User Product in which it has been modified or installed. Access to a
|
||||
network may be denied when the modification itself materially and
|
||||
adversely affects the operation of the network or violates the rules and
|
||||
protocols for communication across the network.
|
||||
|
||||
Corresponding Source conveyed, and Installation Information provided,
|
||||
in accord with this section must be in a format that is publicly
|
||||
documented (and with an implementation available to the public in
|
||||
source code form), and must require no special password or key for
|
||||
unpacking, reading or copying.
|
||||
|
||||
7. Additional Terms.
|
||||
|
||||
"Additional permissions" are terms that supplement the terms of this
|
||||
License by making exceptions from one or more of its conditions.
|
||||
Additional permissions that are applicable to the entire Program shall
|
||||
be treated as though they were included in this License, to the extent
|
||||
that they are valid under applicable law. If additional permissions
|
||||
apply only to part of the Program, that part may be used separately
|
||||
under those permissions, but the entire Program remains governed by
|
||||
this License without regard to the additional permissions.
|
||||
|
||||
When you convey a copy of a covered work, you may at your option
|
||||
remove any additional permissions from that copy, or from any part of
|
||||
it. (Additional permissions may be written to require their own
|
||||
removal in certain cases when you modify the work.) You may place
|
||||
additional permissions on material, added by you to a covered work,
|
||||
for which you have or can give appropriate copyright permission.
|
||||
|
||||
Notwithstanding any other provision of this License, for material you
|
||||
add to a covered work, you may (if authorized by the copyright holders of
|
||||
that material) supplement the terms of this License with terms:
|
||||
|
||||
a) Disclaiming warranty or limiting liability differently from the
|
||||
terms of sections 15 and 16 of this License; or
|
||||
|
||||
b) Requiring preservation of specified reasonable legal notices or
|
||||
author attributions in that material or in the Appropriate Legal
|
||||
Notices displayed by works containing it; or
|
||||
|
||||
c) Prohibiting misrepresentation of the origin of that material, or
|
||||
requiring that modified versions of such material be marked in
|
||||
reasonable ways as different from the original version; or
|
||||
|
||||
d) Limiting the use for publicity purposes of names of licensors or
|
||||
authors of the material; or
|
||||
|
||||
e) Declining to grant rights under trademark law for use of some
|
||||
trade names, trademarks, or service marks; or
|
||||
|
||||
f) Requiring indemnification of licensors and authors of that
|
||||
material by anyone who conveys the material (or modified versions of
|
||||
it) with contractual assumptions of liability to the recipient, for
|
||||
any liability that these contractual assumptions directly impose on
|
||||
those licensors and authors.
|
||||
|
||||
All other non-permissive additional terms are considered "further
|
||||
restrictions" within the meaning of section 10. If the Program as you
|
||||
received it, or any part of it, contains a notice stating that it is
|
||||
governed by this License along with a term that is a further
|
||||
restriction, you may remove that term. If a license document contains
|
||||
a further restriction but permits relicensing or conveying under this
|
||||
License, you may add to a covered work material governed by the terms
|
||||
of that license document, provided that the further restriction does
|
||||
not survive such relicensing or conveying.
|
||||
|
||||
If you add terms to a covered work in accord with this section, you
|
||||
must place, in the relevant source files, a statement of the
|
||||
additional terms that apply to those files, or a notice indicating
|
||||
where to find the applicable terms.
|
||||
|
||||
Additional terms, permissive or non-permissive, may be stated in the
|
||||
form of a separately written license, or stated as exceptions;
|
||||
the above requirements apply either way.
|
||||
|
||||
8. Termination.
|
||||
|
||||
You may not propagate or modify a covered work except as expressly
|
||||
provided under this License. Any attempt otherwise to propagate or
|
||||
modify it is void, and will automatically terminate your rights under
|
||||
this License (including any patent licenses granted under the third
|
||||
paragraph of section 11).
|
||||
|
||||
However, if you cease all violation of this License, then your
|
||||
license from a particular copyright holder is reinstated (a)
|
||||
provisionally, unless and until the copyright holder explicitly and
|
||||
finally terminates your license, and (b) permanently, if the copyright
|
||||
holder fails to notify you of the violation by some reasonable means
|
||||
prior to 60 days after the cessation.
|
||||
|
||||
Moreover, your license from a particular copyright holder is
|
||||
reinstated permanently if the copyright holder notifies you of the
|
||||
violation by some reasonable means, this is the first time you have
|
||||
received notice of violation of this License (for any work) from that
|
||||
copyright holder, and you cure the violation prior to 30 days after
|
||||
your receipt of the notice.
|
||||
|
||||
Termination of your rights under this section does not terminate the
|
||||
licenses of parties who have received copies or rights from you under
|
||||
this License. If your rights have been terminated and not permanently
|
||||
reinstated, you do not qualify to receive new licenses for the same
|
||||
material under section 10.
|
||||
|
||||
9. Acceptance Not Required for Having Copies.
|
||||
|
||||
You are not required to accept this License in order to receive or
|
||||
run a copy of the Program. Ancillary propagation of a covered work
|
||||
occurring solely as a consequence of using peer-to-peer transmission
|
||||
to receive a copy likewise does not require acceptance. However,
|
||||
nothing other than this License grants you permission to propagate or
|
||||
modify any covered work. These actions infringe copyright if you do
|
||||
not accept this License. Therefore, by modifying or propagating a
|
||||
covered work, you indicate your acceptance of this License to do so.
|
||||
|
||||
10. Automatic Licensing of Downstream Recipients.
|
||||
|
||||
Each time you convey a covered work, the recipient automatically
|
||||
receives a license from the original licensors, to run, modify and
|
||||
propagate that work, subject to this License. You are not responsible
|
||||
for enforcing compliance by third parties with this License.
|
||||
|
||||
An "entity transaction" is a transaction transferring control of an
|
||||
organization, or substantially all assets of one, or subdividing an
|
||||
organization, or merging organizations. If propagation of a covered
|
||||
work results from an entity transaction, each party to that
|
||||
transaction who receives a copy of the work also receives whatever
|
||||
licenses to the work the party's predecessor in interest had or could
|
||||
give under the previous paragraph, plus a right to possession of the
|
||||
Corresponding Source of the work from the predecessor in interest, if
|
||||
the predecessor has it or can get it with reasonable efforts.
|
||||
|
||||
You may not impose any further restrictions on the exercise of the
|
||||
rights granted or affirmed under this License. For example, you may
|
||||
not impose a license fee, royalty, or other charge for exercise of
|
||||
rights granted under this License, and you may not initiate litigation
|
||||
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||
any patent claim is infringed by making, using, selling, offering for
|
||||
sale, or importing the Program or any portion of it.
|
||||
|
||||
11. Patents.
|
||||
|
||||
A "contributor" is a copyright holder who authorizes use under this
|
||||
License of the Program or a work on which the Program is based. The
|
||||
work thus licensed is called the contributor's "contributor version".
|
||||
|
||||
A contributor's "essential patent claims" are all patent claims
|
||||
owned or controlled by the contributor, whether already acquired or
|
||||
hereafter acquired, that would be infringed by some manner, permitted
|
||||
by this License, of making, using, or selling its contributor version,
|
||||
but do not include claims that would be infringed only as a
|
||||
consequence of further modification of the contributor version. For
|
||||
purposes of this definition, "control" includes the right to grant
|
||||
patent sublicenses in a manner consistent with the requirements of
|
||||
this License.
|
||||
|
||||
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||
patent license under the contributor's essential patent claims, to
|
||||
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||
propagate the contents of its contributor version.
|
||||
|
||||
In the following three paragraphs, a "patent license" is any express
|
||||
agreement or commitment, however denominated, not to enforce a patent
|
||||
(such as an express permission to practice a patent or covenant not to
|
||||
sue for patent infringement). To "grant" such a patent license to a
|
||||
party means to make such an agreement or commitment not to enforce a
|
||||
patent against the party.
|
||||
|
||||
If you convey a covered work, knowingly relying on a patent license,
|
||||
and the Corresponding Source of the work is not available for anyone
|
||||
to copy, free of charge and under the terms of this License, through a
|
||||
publicly available network server or other readily accessible means,
|
||||
then you must either (1) cause the Corresponding Source to be so
|
||||
available, or (2) arrange to deprive yourself of the benefit of the
|
||||
patent license for this particular work, or (3) arrange, in a manner
|
||||
consistent with the requirements of this License, to extend the patent
|
||||
license to downstream recipients. "Knowingly relying" means you have
|
||||
actual knowledge that, but for the patent license, your conveying the
|
||||
covered work in a country, or your recipient's use of the covered work
|
||||
in a country, would infringe one or more identifiable patents in that
|
||||
country that you have reason to believe are valid.
|
||||
|
||||
If, pursuant to or in connection with a single transaction or
|
||||
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||
covered work, and grant a patent license to some of the parties
|
||||
receiving the covered work authorizing them to use, propagate, modify
|
||||
or convey a specific copy of the covered work, then the patent license
|
||||
you grant is automatically extended to all recipients of the covered
|
||||
work and works based on it.
|
||||
|
||||
A patent license is "discriminatory" if it does not include within
|
||||
the scope of its coverage, prohibits the exercise of, or is
|
||||
conditioned on the non-exercise of one or more of the rights that are
|
||||
specifically granted under this License. You may not convey a covered
|
||||
work if you are a party to an arrangement with a third party that is
|
||||
in the business of distributing software, under which you make payment
|
||||
to the third party based on the extent of your activity of conveying
|
||||
the work, and under which the third party grants, to any of the
|
||||
parties who would receive the covered work from you, a discriminatory
|
||||
patent license (a) in connection with copies of the covered work
|
||||
conveyed by you (or copies made from those copies), or (b) primarily
|
||||
for and in connection with specific products or compilations that
|
||||
contain the covered work, unless you entered into that arrangement,
|
||||
or that patent license was granted, prior to 28 March 2007.
|
||||
|
||||
Nothing in this License shall be construed as excluding or limiting
|
||||
any implied license or other defenses to infringement that may
|
||||
otherwise be available to you under applicable patent law.
|
||||
|
||||
12. No Surrender of Others' Freedom.
|
||||
|
||||
If conditions are imposed on you (whether by court order, agreement or
|
||||
otherwise) that contradict the conditions of this License, they do not
|
||||
excuse you from the conditions of this License. If you cannot convey a
|
||||
covered work so as to satisfy simultaneously your obligations under this
|
||||
License and any other pertinent obligations, then as a consequence you may
|
||||
not convey it at all. For example, if you agree to terms that obligate you
|
||||
to collect a royalty for further conveying from those to whom you convey
|
||||
the Program, the only way you could satisfy both those terms and this
|
||||
License would be to refrain entirely from conveying the Program.
|
||||
|
||||
13. Remote Network Interaction; Use with the GNU General Public License.
|
||||
|
||||
Notwithstanding any other provision of this License, if you modify the
|
||||
Program, your modified version must prominently offer all users
|
||||
interacting with it remotely through a computer network (if your version
|
||||
supports such interaction) an opportunity to receive the Corresponding
|
||||
Source of your version by providing access to the Corresponding Source
|
||||
from a network server at no charge, through some standard or customary
|
||||
means of facilitating copying of software. This Corresponding Source
|
||||
shall include the Corresponding Source for any work covered by version 3
|
||||
of the GNU General Public License that is incorporated pursuant to the
|
||||
following paragraph.
|
||||
|
||||
Notwithstanding any other provision of this License, you have
|
||||
permission to link or combine any covered work with a work licensed
|
||||
under version 3 of the GNU General Public License into a single
|
||||
combined work, and to convey the resulting work. The terms of this
|
||||
License will continue to apply to the part which is the covered work,
|
||||
but the work with which it is combined will remain governed by version
|
||||
3 of the GNU General Public License.
|
||||
|
||||
14. Revised Versions of this License.
|
||||
|
||||
The Free Software Foundation may publish revised and/or new versions of
|
||||
the GNU Affero General Public License from time to time. Such new versions
|
||||
will be similar in spirit to the present version, but may differ in detail to
|
||||
address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the
|
||||
Program specifies that a certain numbered version of the GNU Affero General
|
||||
Public License "or any later version" applies to it, you have the
|
||||
option of following the terms and conditions either of that numbered
|
||||
version or of any later version published by the Free Software
|
||||
Foundation. If the Program does not specify a version number of the
|
||||
GNU Affero General Public License, you may choose any version ever published
|
||||
by the Free Software Foundation.
|
||||
|
||||
If the Program specifies that a proxy can decide which future
|
||||
versions of the GNU Affero General Public License can be used, that proxy's
|
||||
public statement of acceptance of a version permanently authorizes you
|
||||
to choose that version for the Program.
|
||||
|
||||
Later license versions may give you additional or different
|
||||
permissions. However, no additional obligations are imposed on any
|
||||
author or copyright holder as a result of your choosing to follow a
|
||||
later version.
|
||||
|
||||
15. Disclaimer of Warranty.
|
||||
|
||||
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||
|
||||
16. Limitation of Liability.
|
||||
|
||||
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||
SUCH DAMAGES.
|
||||
|
||||
17. Interpretation of Sections 15 and 16.
|
||||
|
||||
If the disclaimer of warranty and limitation of liability provided
|
||||
above cannot be given local legal effect according to their terms,
|
||||
reviewing courts shall apply local law that most closely approximates
|
||||
an absolute waiver of all civil liability in connection with the
|
||||
Program, unless a warranty or assumption of liability accompanies a
|
||||
copy of the Program in return for a fee.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
How to Apply These Terms to Your New Programs
|
||||
|
||||
If you develop a new program, and you want it to be of the greatest
|
||||
possible use to the public, the best way to achieve this is to make it
|
||||
free software which everyone can redistribute and change under these terms.
|
||||
|
||||
To do so, attach the following notices to the program. It is safest
|
||||
to attach them to the start of each source file to most effectively
|
||||
state the exclusion of warranty; and each file should have at least
|
||||
the "copyright" line and a pointer to where the full notice is found.
|
||||
|
||||
<one line to give the program's name and a brief idea of what it does.>
|
||||
Copyright (C) <year> <name of author>
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
If your software can interact with users remotely through a computer
|
||||
network, you should also make sure that it provides a way for users to
|
||||
get its source. For example, if your program is a web application, its
|
||||
interface could display a "Source" link that leads users to an archive
|
||||
of the code. There are many ways you could offer source, and different
|
||||
solutions will be better for different programs; see section 13 for the
|
||||
specific requirements.
|
||||
|
||||
You should also get your employer (if you work as a programmer) or school,
|
||||
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||
For more information on this, and how to apply and follow the GNU AGPL, see
|
||||
<https://www.gnu.org/licenses/>.
|
||||
@@ -18,6 +18,7 @@
|
||||
<img alt="Self-hosted" src="https://img.shields.io/badge/self--hosted-one%20command-1677ff">
|
||||
<img alt="Docker Compose" src="https://img.shields.io/badge/docker-compose-2496ED?logo=docker&logoColor=white">
|
||||
<img alt="Bring your own model" src="https://img.shields.io/badge/AI-bring%20your%20own%20model-6f42c1">
|
||||
<img alt="License: AGPL-3.0" src="https://img.shields.io/badge/license-AGPL--3.0-blue">
|
||||
</p>
|
||||
|
||||
---
|
||||
@@ -36,6 +37,7 @@
|
||||
- [Updating](#updating)
|
||||
- [Features](#features)
|
||||
- [Troubleshooting](#troubleshooting)
|
||||
- [License](#license)
|
||||
|
||||
---
|
||||
|
||||
@@ -130,7 +132,9 @@ By default files are stored in a Docker volume. In **Settings → Storage** the
|
||||
|
||||
### Updating
|
||||
|
||||
Download the new release's `compose.yaml` over the old one and restart:
|
||||
When a new release is out, the installation administrator sees a banner in the interface and updates with one button; the same lives in **Settings → Platform → Updates**. The installation updates itself on the server: it downloads the release `compose.yaml`, pulls the images and restarts the services, with about a minute of downtime.
|
||||
|
||||
Manually, from the server console: download the new release's `compose.yaml` over the old one and restart:
|
||||
|
||||
```bash
|
||||
docker compose pull && docker compose up -d --wait
|
||||
@@ -309,15 +313,21 @@ All services should be `healthy` or `running`. Application readiness is availabl
|
||||
<details>
|
||||
<summary><strong>Do not delete the secrets volume</strong></summary>
|
||||
|
||||
The `chatballs-secrets` volume holds the encryption key. Without it, integration tokens, SMTP passwords, S3 keys and two-factor secrets become unreadable. Include this volume in backups together with the database and files.
|
||||
The `chatballs-secrets` volume holds the encryption key. Without it, integration tokens, SMTP passwords, S3 keys and two-factor secrets become unreadable. The `chatballs-secrets-platform` and `chatballs-secrets-schema` volumes hold the database role passwords: without them the stack cannot connect to its own database. Include all three volumes in backups together with the database and files.
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>Backup</strong></summary>
|
||||
|
||||
Copy the `chatballs-postgres`, `chatballs-media` and `chatballs-secrets` volumes. A database dump can be taken as well:
|
||||
Copy the `chatballs-postgres`, `chatballs-media`, `chatballs-secrets`, `chatballs-secrets-platform` and `chatballs-secrets-schema` volumes. A database dump can be taken as well:
|
||||
|
||||
```bash
|
||||
docker compose exec -T postgres pg_dump -U chatballs_bootstrap chatballs > backup.sql
|
||||
```
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
## License
|
||||
|
||||
Chatballs is distributed under the [GNU Affero General Public License v3.0](LICENSE). You may use, modify and self-host it freely. If you modify it and offer it to others as a service, you must publish your changes under the same license.
|
||||
+13
-3
@@ -18,6 +18,7 @@
|
||||
<img alt="Self-hosted" src="https://img.shields.io/badge/self--hosted-one%20command-1677ff">
|
||||
<img alt="Docker Compose" src="https://img.shields.io/badge/docker-compose-2496ED?logo=docker&logoColor=white">
|
||||
<img alt="Bring your own model" src="https://img.shields.io/badge/AI-bring%20your%20own%20model-6f42c1">
|
||||
<img alt="License: AGPL-3.0" src="https://img.shields.io/badge/license-AGPL--3.0-blue">
|
||||
</p>
|
||||
|
||||
---
|
||||
@@ -36,6 +37,7 @@
|
||||
- [Обновление](#обновление)
|
||||
- [Функции](#функции)
|
||||
- [Решение проблем](#решение-проблем)
|
||||
- [Лицензия](#лицензия)
|
||||
|
||||
---
|
||||
|
||||
@@ -130,7 +132,9 @@ Relay слушает выделенный IP, чтобы порт 443 не ко
|
||||
|
||||
### Обновление
|
||||
|
||||
Скачайте `compose.yaml` нового релиза поверх старого и повторите запуск:
|
||||
Когда выходит новый релиз, администратор установки видит баннер в интерфейсе и обновляется одной кнопкой; то же есть в **Настройки → Платформа → Обновления**. Установка идёт на сервере сама: скачивается `compose.yaml` релиза, загружаются образы, сервисы перезапускаются, приложение недоступно около минуты.
|
||||
|
||||
Вручную, из консоли сервера: скачайте `compose.yaml` нового релиза поверх старого и повторите запуск:
|
||||
|
||||
```bash
|
||||
docker compose pull && docker compose up -d --wait
|
||||
@@ -309,15 +313,21 @@ docker compose ps
|
||||
<details>
|
||||
<summary><strong>Не удаляйте том с секретами</strong></summary>
|
||||
|
||||
В томе `chatballs-secrets` лежит ключ шифрования. Без него станут нечитаемы токены интеграций, пароли SMTP, ключи S3 и секреты двухфакторной защиты. Включайте этот том в резервные копии вместе с базой и файлами.
|
||||
В томе `chatballs-secrets` лежит ключ шифрования. Без него станут нечитаемы токены интеграций, пароли SMTP, ключи S3 и секреты двухфакторной защиты. В томах `chatballs-secrets-platform` и `chatballs-secrets-schema` лежат пароли ролей базы: без них стек не подключится к собственной базе. Включайте все три тома в резервные копии вместе с базой и файлами.
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>Резервная копия</strong></summary>
|
||||
|
||||
Копируйте тома `chatballs-postgres`, `chatballs-media` и `chatballs-secrets`. Для базы можно снять дамп:
|
||||
Копируйте тома `chatballs-postgres`, `chatballs-media`, `chatballs-secrets`, `chatballs-secrets-platform` и `chatballs-secrets-schema`. Для базы можно снять дамп:
|
||||
|
||||
```bash
|
||||
docker compose exec -T postgres pg_dump -U chatballs_bootstrap chatballs > backup.sql
|
||||
```
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
## Лицензия
|
||||
|
||||
Chatballs распространяется по лицензии [GNU Affero General Public License v3.0](LICENSE). Продукт можно свободно использовать, менять и ставить у себя. Если вы меняете его и предоставляете другим как сервис, изменения нужно опубликовать под той же лицензией.
|
||||
@@ -6,6 +6,11 @@ ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Версия релиза попадает в образ при сборке: по ней приложение решает,
|
||||
# есть ли обновление. Сборка из исходников остаётся «dev».
|
||||
ARG CHATBALLS_VERSION=dev
|
||||
ENV CHATBALLS_VERSION=${CHATBALLS_VERSION}
|
||||
|
||||
RUN addgroup --system hub && adduser --system --ingroup hub hub
|
||||
|
||||
COPY apps/backend/requirements.txt /app/apps/backend/requirements.txt
|
||||
|
||||
@@ -19,6 +19,7 @@ from chatballs.ai.agent_knowledge import (
|
||||
from chatballs.ai.knowledge_policy import readable_knowledge
|
||||
from chatballs.ai.models import AIAgent, Knowledge
|
||||
from chatballs.channels.models import Channel
|
||||
from chatballs.i18n import t
|
||||
from chatballs.support_portals.models import PortalArticle
|
||||
from chatballs.tenancy.context import TenantContext
|
||||
|
||||
@@ -37,17 +38,17 @@ def normalized_ids(raw_ids: Iterable[int], field: str) -> list[int]:
|
||||
normalized: list[int] = []
|
||||
for value in raw_ids:
|
||||
if isinstance(value, bool) or not isinstance(value, int) or value <= 0:
|
||||
raise ValidationError({field: "Positive integer ID required"})
|
||||
raise ValidationError({field: t("api.positive_id_required")})
|
||||
if value not in normalized:
|
||||
normalized.append(value)
|
||||
if not normalized:
|
||||
raise ValidationError({field: "At least one ID is required"})
|
||||
raise ValidationError({field: t("api.at_least_one_id")})
|
||||
return normalized
|
||||
|
||||
|
||||
def _locked_agent(*, context: TenantContext, agent: AIAgent) -> tuple[AIAgent, Channel]:
|
||||
if agent.channel.organization_id != context.organization_id:
|
||||
raise ValidationError({"agent": "Agent belongs to another organization"})
|
||||
raise ValidationError({"agent": t("ai.agent_other_organization")})
|
||||
channel = Channel.objects.select_for_update().get(
|
||||
id=agent.channel_id,
|
||||
organization_id=context.organization_id,
|
||||
@@ -99,7 +100,7 @@ def link_knowledge_to_agent(
|
||||
)
|
||||
found_ids = set(readable.values_list("id", flat=True))
|
||||
if len(found_ids) != len(requested_ids):
|
||||
raise ValidationError({"knowledgeIds": "Unknown knowledge item"})
|
||||
raise ValidationError({"knowledgeIds": t("ai.unknown_knowledge_item")})
|
||||
available_ids = set(
|
||||
knowledge_available_to_channel(
|
||||
Knowledge.objects.filter(id__in=found_ids),
|
||||
@@ -130,7 +131,7 @@ def link_portal_articles_to_agent(
|
||||
)
|
||||
found_ids = set(articles.values_list("id", flat=True))
|
||||
if len(found_ids) != len(requested_ids):
|
||||
raise ValidationError({"articleIds": "Unknown portal article"})
|
||||
raise ValidationError({"articleIds": t("ai.unknown_portal_article")})
|
||||
available_ids = set(
|
||||
portal_articles_available_to_channel(
|
||||
PortalArticle.objects.filter(id__in=found_ids),
|
||||
|
||||
@@ -12,7 +12,7 @@ from django.db import transaction
|
||||
from django.db.models import Case, Count, IntegerField, Q, QuerySet, Value, When
|
||||
from django.utils.text import slugify
|
||||
|
||||
from chatballs.ai.models import AIAgent, AIAgentStatus
|
||||
from chatballs.ai.models import AIAgent, AIAgentStatus, AnswerLanguage
|
||||
from chatballs.ai.serializers import agent_portal_article_payload
|
||||
from chatballs.channels.models import Channel
|
||||
from chatballs.channels.services import (
|
||||
@@ -22,6 +22,7 @@ from chatballs.channels.services import (
|
||||
update_channel,
|
||||
)
|
||||
from chatballs.conversations.models import LifecycleState
|
||||
from chatballs.i18n import normalize_language, t
|
||||
from chatballs.tenancy.context import TenantContext
|
||||
|
||||
|
||||
@@ -128,6 +129,7 @@ def agent_card_payload(channel: Channel, *, knowledge_total: int | None = None)
|
||||
"providerIntegrationId": agent.provider_integration_id,
|
||||
"modelParams": agent.model_params,
|
||||
"limits": agent.limits,
|
||||
"answerLanguage": agent.answer_language,
|
||||
"persona": agent.persona,
|
||||
"tone": agent.tone,
|
||||
"instructions": agent.instructions,
|
||||
@@ -170,7 +172,7 @@ def _unique_agent_code(organization_id: int, name: str) -> str:
|
||||
candidate = f"{base}-{suffix}"
|
||||
if candidate not in taken:
|
||||
return candidate
|
||||
raise ValidationError({"name": "Не удалось подобрать уникальный код агента"})
|
||||
raise ValidationError({"name": t("ai.agent_code_collision")})
|
||||
|
||||
|
||||
def ensure_channel_agent(channel: Channel) -> AIAgent:
|
||||
@@ -200,7 +202,7 @@ def create_agent_card(
|
||||
from chatballs.channels import authorization
|
||||
from chatballs.channels.services import _clean_name, _group_for_channel
|
||||
|
||||
authorization.require_organization_manage(context, operation="Создание агента")
|
||||
authorization.require_organization_manage(context, operation="channels.operation_agent_create")
|
||||
clean_name = _clean_name(name)
|
||||
group = _group_for_channel(context=context, group_id=group_id)
|
||||
channel = Channel.objects.create(
|
||||
@@ -234,6 +236,7 @@ def update_agent_card(
|
||||
"persona",
|
||||
"tone",
|
||||
"instructions",
|
||||
"answerLanguage",
|
||||
"knowledgeIds",
|
||||
}
|
||||
if ai_fields & set(body):
|
||||
@@ -244,20 +247,20 @@ def update_agent_card(
|
||||
not isinstance(knowledge_ids, list)
|
||||
or not all(isinstance(item, int) for item in knowledge_ids)
|
||||
):
|
||||
raise ValidationError({"knowledgeIds": "List of ids required"})
|
||||
raise ValidationError({"knowledgeIds": t("api.list_of_ids_required")})
|
||||
model_params = body.get("modelParams", agent.model_params)
|
||||
limits = body.get("limits", agent.limits)
|
||||
if not isinstance(model_params, dict):
|
||||
raise ValidationError({"modelParams": "Object required"})
|
||||
raise ValidationError({"modelParams": t("api.object_required")})
|
||||
if not isinstance(limits, dict):
|
||||
raise ValidationError({"limits": "Object required"})
|
||||
raise ValidationError({"limits": t("api.object_required")})
|
||||
provider_integration_id = body.get(
|
||||
"providerIntegrationId", agent.provider_integration_id
|
||||
)
|
||||
if provider_integration_id is not None and not isinstance(
|
||||
provider_integration_id, int
|
||||
):
|
||||
raise ValidationError({"providerIntegrationId": "Integer id required"})
|
||||
raise ValidationError({"providerIntegrationId": t("api.integer_id_required")})
|
||||
update_agent(
|
||||
context=context,
|
||||
agent=agent,
|
||||
@@ -271,6 +274,9 @@ def update_agent_card(
|
||||
persona=str(body.get("persona", agent.persona)),
|
||||
tone=str(body.get("tone", agent.tone)),
|
||||
instructions=str(body.get("instructions", agent.instructions)),
|
||||
answer_language=_clean_answer_language(
|
||||
body.get("answerLanguage", agent.answer_language)
|
||||
),
|
||||
knowledge_ids=knowledge_ids,
|
||||
),
|
||||
)
|
||||
@@ -280,6 +286,18 @@ def update_agent_card(
|
||||
return agent_card_for_context(context=context, agent_id=channel.id)
|
||||
|
||||
|
||||
def _clean_answer_language(value: object) -> str:
|
||||
"""Режим ответа агента: MIRROR, ORGANIZATION или код поддерживаемого языка."""
|
||||
|
||||
raw = str(value or "").strip()
|
||||
if raw in AnswerLanguage.values:
|
||||
return raw
|
||||
code = normalize_language(raw)
|
||||
if code:
|
||||
return code
|
||||
raise ValidationError({"answerLanguage": t("ai.unknown_answer_language")})
|
||||
|
||||
|
||||
def agent_deletion_blockers(channel: Channel) -> list[dict[str, object]]:
|
||||
"""Агент удаляется вместе с каналом; блокируют только внешние связи."""
|
||||
counts = (
|
||||
@@ -295,7 +313,7 @@ def delete_agent_card(*, context: TenantContext, channel: Channel) -> None:
|
||||
from chatballs.channels import authorization
|
||||
from chatballs.channels.services import ChannelHasReferences
|
||||
|
||||
authorization.require_organization_manage(context, operation="Удаление агента")
|
||||
authorization.require_organization_manage(context, operation="channels.operation_agent_delete")
|
||||
blockers = agent_deletion_blockers(channel)
|
||||
if blockers:
|
||||
raise ChannelHasReferences(blockers)
|
||||
|
||||
@@ -24,9 +24,14 @@ from chatballs.channels import services as channel_services
|
||||
from chatballs.channels.models import Channel
|
||||
from chatballs.channels.runtime import run_channel_turn
|
||||
from chatballs.channels.selectors import channel_for_context
|
||||
from chatballs.i18n import t
|
||||
from chatballs.identity.audit import record_audit_event
|
||||
|
||||
AGENT_NOT_FOUND = {"detail": "Агент не найден"}
|
||||
|
||||
# Функция, а не константа: язык у каждого запроса свой, а константа собралась бы
|
||||
# один раз при импорте — на языке, который случайно стоял в тот момент.
|
||||
def agent_not_found() -> dict[str, str]:
|
||||
return {"detail": t("ai.agent_not_found")}
|
||||
|
||||
|
||||
def _validation_detail(error: ValidationError) -> str:
|
||||
@@ -105,7 +110,7 @@ class AgentCardListView(APIView):
|
||||
try:
|
||||
cards = cards.filter(group_id=int(group))
|
||||
except ValueError:
|
||||
return Response({"detail": "group must be an id or none"}, status=400)
|
||||
return Response({"detail": t("ai.group_id_or_none")}, status=400)
|
||||
from chatballs.ai.agent_card import (
|
||||
ensure_channel_agent,
|
||||
knowledge_total_for_organization,
|
||||
@@ -130,7 +135,7 @@ class AgentCardListView(APIView):
|
||||
if group_id is not None and (
|
||||
isinstance(group_id, bool) or not isinstance(group_id, int)
|
||||
):
|
||||
return Response({"detail": "groupId must be an integer or null"}, status=400)
|
||||
return Response({"detail": t("ai.group_id_integer_or_null")}, status=400)
|
||||
try:
|
||||
channel = create_agent_card(
|
||||
context=request.tenant_context,
|
||||
@@ -157,7 +162,7 @@ class AgentCardDetailView(APIView):
|
||||
try:
|
||||
channel = _load(request, agent_id)
|
||||
except Channel.DoesNotExist:
|
||||
return Response(AGENT_NOT_FOUND, status=404)
|
||||
return Response(agent_not_found(), status=404)
|
||||
from chatballs.ai.agent_card import ensure_channel_agent
|
||||
|
||||
ensure_channel_agent(channel)
|
||||
@@ -167,7 +172,7 @@ class AgentCardDetailView(APIView):
|
||||
try:
|
||||
channel = _load(request, agent_id)
|
||||
except Channel.DoesNotExist:
|
||||
return Response(AGENT_NOT_FOUND, status=404)
|
||||
return Response(agent_not_found(), status=404)
|
||||
body = request.data if isinstance(request.data, dict) else {}
|
||||
try:
|
||||
channel = update_agent_card(
|
||||
@@ -182,7 +187,7 @@ class AgentCardDetailView(APIView):
|
||||
try:
|
||||
channel = _load(request, agent_id)
|
||||
except Channel.DoesNotExist:
|
||||
return Response(AGENT_NOT_FOUND, status=404)
|
||||
return Response(agent_not_found(), status=404)
|
||||
name = channel.name
|
||||
try:
|
||||
delete_agent_card(context=request.tenant_context, channel=channel)
|
||||
@@ -201,7 +206,7 @@ class _AgentCardStatusView(APIView):
|
||||
try:
|
||||
channel = _load(request, agent_id)
|
||||
except Channel.DoesNotExist:
|
||||
return Response(AGENT_NOT_FOUND, status=404)
|
||||
return Response(agent_not_found(), status=404)
|
||||
try:
|
||||
channel = set_agent_card_active(
|
||||
context=request.tenant_context,
|
||||
@@ -234,17 +239,17 @@ class AgentCardTestChatView(APIView):
|
||||
capability="ai.view",
|
||||
)
|
||||
except Channel.DoesNotExist:
|
||||
return Response(AGENT_NOT_FOUND, status=404)
|
||||
return Response(agent_not_found(), status=404)
|
||||
message = str(request.data.get("message", "")).strip()
|
||||
if not message:
|
||||
return Response({"detail": "Пустое сообщение"}, status=400)
|
||||
return Response({"detail": t("ai.empty_message")}, status=400)
|
||||
history = request.data.get("history") or []
|
||||
if not isinstance(history, list):
|
||||
return Response({"detail": "history must be a list"}, status=400)
|
||||
return Response({"detail": t("ai.history_must_be_list")}, status=400)
|
||||
try:
|
||||
result = run_channel_turn(channel=channel, message=message, history=history)
|
||||
except ProviderError as error:
|
||||
return Response({"detail": f"Ошибка провайдера: {error}"}, status=502)
|
||||
return Response({"detail": t("ai.provider_error", error=error)}, status=502)
|
||||
return Response(
|
||||
{
|
||||
"reply": result.text,
|
||||
@@ -263,11 +268,11 @@ class AgentCardConnectionsView(APIView):
|
||||
try:
|
||||
channel = _load(request, agent_id)
|
||||
except Channel.DoesNotExist:
|
||||
return Response(AGENT_NOT_FOUND, status=404)
|
||||
return Response(agent_not_found(), status=404)
|
||||
data = request.data if isinstance(request.data, dict) else {}
|
||||
integration_id = data.get("integrationId")
|
||||
if isinstance(integration_id, bool) or not isinstance(integration_id, int):
|
||||
return Response({"detail": "integrationId must be an integer"}, status=400)
|
||||
return Response({"detail": t("ai.integration_id_integer")}, status=400)
|
||||
try:
|
||||
integration, previous_channel_id = channel_services.bind_connection(
|
||||
context=request.tenant_context,
|
||||
@@ -305,7 +310,7 @@ class AgentCardConnectionDetailView(APIView):
|
||||
try:
|
||||
channel = _load(request, agent_id)
|
||||
except Channel.DoesNotExist:
|
||||
return Response(AGENT_NOT_FOUND, status=404)
|
||||
return Response(agent_not_found(), status=404)
|
||||
try:
|
||||
channel_services.unbind_connection(
|
||||
context=request.tenant_context,
|
||||
|
||||
@@ -4,10 +4,10 @@ from rest_framework.request import Request
|
||||
from rest_framework.views import APIView
|
||||
|
||||
from chatballs.ai.models import KnowledgeAttachment
|
||||
from chatballs.identity.models import Organization
|
||||
from chatballs.tenancy.context import TenantContext
|
||||
from chatballs.tenancy.database import tenant_atomic
|
||||
from chatballs.tenancy.ingress import attachment_route
|
||||
from chatballs.tenancy.lookup import load_organization
|
||||
|
||||
|
||||
class AttachmentDownloadView(APIView):
|
||||
@@ -19,10 +19,9 @@ class AttachmentDownloadView(APIView):
|
||||
route = attachment_route(str(public_id))
|
||||
if route is None:
|
||||
raise Http404
|
||||
try:
|
||||
organization = Organization.objects.get(pk=route.organization_id)
|
||||
except Organization.DoesNotExist as error:
|
||||
raise Http404 from error
|
||||
organization = load_organization(route.organization_id)
|
||||
if organization is None:
|
||||
raise Http404
|
||||
context = TenantContext.for_resource(organization)
|
||||
with tenant_atomic(context):
|
||||
attachment = KnowledgeAttachment.objects.filter(
|
||||
|
||||
@@ -16,19 +16,20 @@ from chatballs.ai.knowledge_bulk import (
|
||||
from chatballs.ai.models import AIAgent
|
||||
from chatballs.ai.selectors import agent_for_employee
|
||||
from chatballs.api.permissions import HasCapability
|
||||
from chatballs.i18n import t
|
||||
from chatballs.identity.audit import record_audit_event
|
||||
|
||||
|
||||
def _positive_id(value: object, field: str) -> int:
|
||||
if isinstance(value, bool) or not isinstance(value, int) or value <= 0:
|
||||
raise ValidationError({field: "Positive integer ID required"})
|
||||
raise ValidationError({field: t("api.positive_id_required")})
|
||||
return value
|
||||
|
||||
|
||||
def _knowledge_ids(body: dict[str, object]) -> list[int]:
|
||||
raw_ids = body.get("knowledgeIds")
|
||||
if not isinstance(raw_ids, list):
|
||||
raise ValidationError({"knowledgeIds": "List of knowledge IDs required"})
|
||||
raise ValidationError({"knowledgeIds": t("ai.list_of_knowledge_ids")})
|
||||
return [_positive_id(item, "knowledgeIds") for item in raw_ids]
|
||||
|
||||
|
||||
@@ -68,7 +69,7 @@ class KnowledgeBulkMoveView(_KnowledgeBulkView):
|
||||
def _attach_action(body: dict[str, object]) -> bool:
|
||||
action = str(body.get("action", "attach"))
|
||||
if action not in {"attach", "detach"}:
|
||||
raise ValidationError({"action": "Expected attach or detach"})
|
||||
raise ValidationError({"action": t("ai.expected_attach_or_detach")})
|
||||
return action == "attach"
|
||||
|
||||
|
||||
@@ -91,7 +92,7 @@ class _AgentLinkView(APIView):
|
||||
agent_id = _positive_id(request.data.get("agentId"), "agentId")
|
||||
raw_ids = request.data.get(self.id_field)
|
||||
if not isinstance(raw_ids, list):
|
||||
raise ValidationError({self.id_field: "List of IDs required"})
|
||||
raise ValidationError({self.id_field: t("ai.list_of_ids")})
|
||||
ids = [_positive_id(item, self.id_field) for item in raw_ids]
|
||||
attach = _attach_action(request.data)
|
||||
except ValidationError as error:
|
||||
@@ -103,7 +104,7 @@ class _AgentLinkView(APIView):
|
||||
capability="ai.manage",
|
||||
)
|
||||
except AIAgent.DoesNotExist:
|
||||
return Response({"detail": "Agent not found"}, status=404)
|
||||
return Response({"detail": t("ai.agent_not_found")}, status=404)
|
||||
try:
|
||||
result = self.link(request, agent, ids, attach)
|
||||
except ValidationError as error:
|
||||
@@ -171,7 +172,7 @@ class AgentCategoryKnowledgeSelectView(APIView):
|
||||
capability="ai.manage",
|
||||
)
|
||||
except AIAgent.DoesNotExist:
|
||||
return Response({"detail": "Agent not found"}, status=404)
|
||||
return Response({"detail": t("ai.agent_not_found")}, status=404)
|
||||
try:
|
||||
category_id = _positive_id(request.data.get("categoryId"), "categoryId")
|
||||
result = add_category_knowledge_to_agent(
|
||||
|
||||
@@ -14,16 +14,17 @@ from chatballs.ai.models import KnowledgeCategory
|
||||
from chatballs.ai.selectors import category_tree_for_employee
|
||||
from chatballs.ai.serializers import category_payload
|
||||
from chatballs.api.permissions import HasCapability
|
||||
from chatballs.i18n import t
|
||||
from chatballs.identity.audit import record_audit_event
|
||||
|
||||
|
||||
def _integer(value: object, field: str) -> int:
|
||||
if isinstance(value, bool):
|
||||
raise ValidationError({field: "Integer required"})
|
||||
raise ValidationError({field: t("api.integer_required")})
|
||||
try:
|
||||
return int(value)
|
||||
except (TypeError, ValueError) as error:
|
||||
raise ValidationError({field: "Integer required"}) from error
|
||||
raise ValidationError({field: t("api.integer_required")}) from error
|
||||
|
||||
|
||||
def _category(request: Request, category_id: int) -> KnowledgeCategory:
|
||||
@@ -42,7 +43,7 @@ def _parent(
|
||||
try:
|
||||
return _category(request, parent_id)
|
||||
except KnowledgeCategory.DoesNotExist as error:
|
||||
raise ValidationError({"parentId": "Parent category not found"}) from error
|
||||
raise ValidationError({"parentId": t("ai.parent_category_not_found")}) from error
|
||||
|
||||
|
||||
def _audit(
|
||||
@@ -110,7 +111,7 @@ class KnowledgeCategoryDetailView(APIView):
|
||||
require_category_manage(context=request.tenant_context)
|
||||
category = _category(request, category_id)
|
||||
except KnowledgeCategory.DoesNotExist:
|
||||
return Response({"detail": "Category not found"}, status=404)
|
||||
return Response({"detail": t("ai.category_not_found")}, status=404)
|
||||
try:
|
||||
parent = (
|
||||
_parent(request, request.data.get("parentId"), field_present=True)
|
||||
@@ -136,7 +137,7 @@ class KnowledgeCategoryDetailView(APIView):
|
||||
require_category_manage(context=request.tenant_context)
|
||||
category = _category(request, category_id)
|
||||
except KnowledgeCategory.DoesNotExist:
|
||||
return Response({"detail": "Category not found"}, status=404)
|
||||
return Response({"detail": t("ai.category_not_found")}, status=404)
|
||||
try:
|
||||
deleted_id = category.id
|
||||
delete_category(context=request.tenant_context, category=category)
|
||||
|
||||
@@ -4,17 +4,18 @@ from rest_framework.request import Request
|
||||
from chatballs.ai.knowledge_services import KnowledgeInput
|
||||
from chatballs.ai.models import Knowledge
|
||||
from chatballs.ai.selectors import KnowledgeFilters
|
||||
from chatballs.i18n import t
|
||||
|
||||
|
||||
def _positive_id(value: object, field: str) -> int:
|
||||
if isinstance(value, bool):
|
||||
raise ValidationError({field: "Positive integer id required"})
|
||||
raise ValidationError({field: t("api.positive_id_required")})
|
||||
try:
|
||||
parsed = int(value)
|
||||
except (TypeError, ValueError) as error:
|
||||
raise ValidationError({field: "Integer id required"}) from error
|
||||
raise ValidationError({field: t("api.integer_id_required")}) from error
|
||||
if parsed <= 0:
|
||||
raise ValidationError({field: "Positive integer id required"})
|
||||
raise ValidationError({field: t("api.positive_id_required")})
|
||||
return parsed
|
||||
|
||||
|
||||
@@ -38,7 +39,7 @@ def knowledge_input(
|
||||
) -> KnowledgeInput:
|
||||
raw_enabled = body.get("isEnabled", current.is_enabled if current else True)
|
||||
if not isinstance(raw_enabled, bool):
|
||||
raise ValidationError({"isEnabled": "Boolean required"})
|
||||
raise ValidationError({"isEnabled": t("api.boolean_required")})
|
||||
return KnowledgeInput(
|
||||
title=str(body.get("title", current.title if current else "")),
|
||||
description=str(
|
||||
@@ -59,7 +60,7 @@ def knowledge_filters(request: Request) -> KnowledgeFilters:
|
||||
elif raw_enabled.lower() == "false":
|
||||
is_enabled = False
|
||||
else:
|
||||
raise ValidationError({"isEnabled": "Boolean required"})
|
||||
raise ValidationError({"isEnabled": t("api.boolean_required")})
|
||||
agents = tuple(
|
||||
int(value) for value in request.query_params.getlist("agent") if value.isdigit()
|
||||
)
|
||||
|
||||
@@ -14,6 +14,7 @@ from chatballs.ai.models import (
|
||||
)
|
||||
from chatballs.ai.services import knowledge_for_agent_ids
|
||||
from chatballs.channels.models import Channel
|
||||
from chatballs.i18n import t
|
||||
from chatballs.tenancy.context import TenantContext
|
||||
|
||||
|
||||
@@ -21,11 +22,11 @@ def _normalized_knowledge_ids(knowledge_ids: Iterable[int]) -> list[int]:
|
||||
normalized: list[int] = []
|
||||
for knowledge_id in knowledge_ids:
|
||||
if isinstance(knowledge_id, bool) or not isinstance(knowledge_id, int):
|
||||
raise ValidationError({"knowledgeIds": "Knowledge IDs must be integers"})
|
||||
raise ValidationError({"knowledgeIds": t("ai.knowledge_ids_integers")})
|
||||
if knowledge_id not in normalized:
|
||||
normalized.append(knowledge_id)
|
||||
if not normalized:
|
||||
raise ValidationError({"knowledgeIds": "At least one knowledge ID is required"})
|
||||
raise ValidationError({"knowledgeIds": t("ai.at_least_one_knowledge_id")})
|
||||
return normalized
|
||||
|
||||
|
||||
@@ -40,7 +41,7 @@ def _locked_knowledge(*, context: TenantContext, knowledge_ids: Iterable[int]) -
|
||||
.order_by("id")
|
||||
)
|
||||
if len(items) != len(normalized_ids):
|
||||
raise ValidationError({"knowledgeIds": "Unknown knowledge item"})
|
||||
raise ValidationError({"knowledgeIds": t("ai.unknown_knowledge_item")})
|
||||
return items
|
||||
|
||||
|
||||
@@ -64,7 +65,7 @@ def bulk_move_knowledge(
|
||||
organization_id=context.organization_id,
|
||||
)
|
||||
except KnowledgeCategory.DoesNotExist as error:
|
||||
raise ValidationError({"categoryId": "Category not found"}) from error
|
||||
raise ValidationError({"categoryId": t("ai.category_not_found")}) from error
|
||||
items = _locked_knowledge(context=context, knowledge_ids=knowledge_ids)
|
||||
_require_bulk_write(context=context, items=items)
|
||||
now = timezone.now()
|
||||
@@ -87,7 +88,7 @@ def add_category_knowledge_to_agent(
|
||||
*, context: TenantContext, agent: AIAgent, category_id: int
|
||||
) -> CategorySelectionResult:
|
||||
if agent.channel.organization_id != context.organization_id:
|
||||
raise ValidationError({"agent": "Agent belongs to another organization"})
|
||||
raise ValidationError({"agent": t("ai.agent_other_organization")})
|
||||
channel = Channel.objects.select_for_update().get(
|
||||
id=agent.channel_id,
|
||||
organization_id=context.organization_id,
|
||||
@@ -96,7 +97,7 @@ def add_category_knowledge_to_agent(
|
||||
id=category_id,
|
||||
organization_id=context.organization_id,
|
||||
).exists():
|
||||
raise ValidationError({"categoryId": "Category not found"})
|
||||
raise ValidationError({"categoryId": t("ai.category_not_found")})
|
||||
|
||||
current_ids = set(agent.knowledge_items.values_list("id", flat=True))
|
||||
category_ids = set(
|
||||
|
||||
@@ -3,6 +3,7 @@ from django.db import transaction
|
||||
|
||||
from chatballs.ai.knowledge_types import UNCATEGORIZED_CATEGORY_NAME
|
||||
from chatballs.ai.models import KnowledgeCategory
|
||||
from chatballs.i18n import t
|
||||
from chatballs.identity.models import Organization
|
||||
from chatballs.tenancy.context import TenantContext
|
||||
|
||||
@@ -24,14 +25,14 @@ def _validate_category_context(
|
||||
*, context: TenantContext, category: KnowledgeCategory
|
||||
) -> None:
|
||||
if category.organization_id != context.organization_id:
|
||||
raise ValidationError({"category": "Category belongs to another organization"})
|
||||
raise ValidationError({"category": t("ai.category_other_organization")})
|
||||
|
||||
|
||||
def _validate_parent(
|
||||
*, context: TenantContext, parent: KnowledgeCategory | None
|
||||
) -> None:
|
||||
if parent is not None and parent.organization_id != context.organization_id:
|
||||
raise ValidationError({"parent": "Parent category belongs to another organization"})
|
||||
raise ValidationError({"parent": t("ai.parent_category_other_organization")})
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
@@ -57,7 +58,7 @@ def rename_category(
|
||||
) -> KnowledgeCategory:
|
||||
_validate_category_context(context=context, category=category)
|
||||
if category.is_system:
|
||||
raise ValidationError({"category": "System category is immutable"})
|
||||
raise ValidationError({"category": t("ai.system_category_immutable")})
|
||||
locked = KnowledgeCategory.objects.select_for_update().get(pk=category.pk)
|
||||
locked.name = name
|
||||
locked.save(update_fields=["name"])
|
||||
@@ -75,7 +76,7 @@ def move_category(
|
||||
_validate_category_context(context=context, category=category)
|
||||
_validate_parent(context=context, parent=parent)
|
||||
if category.is_system:
|
||||
raise ValidationError({"category": "System category is immutable"})
|
||||
raise ValidationError({"category": t("ai.system_category_immutable")})
|
||||
locked_categories = {
|
||||
item.pk: item
|
||||
for item in KnowledgeCategory.objects.select_for_update().filter(
|
||||
@@ -104,7 +105,7 @@ def update_category(
|
||||
_validate_category_context(context=context, category=category)
|
||||
_validate_parent(context=context, parent=parent)
|
||||
if category.is_system:
|
||||
raise ValidationError({"category": "System category is immutable"})
|
||||
raise ValidationError({"category": t("ai.system_category_immutable")})
|
||||
locked_categories = {
|
||||
item.pk: item
|
||||
for item in KnowledgeCategory.objects.select_for_update().filter(
|
||||
|
||||
@@ -13,6 +13,7 @@ from chatballs.ai.knowledge_services import (
|
||||
update_knowledge,
|
||||
)
|
||||
from chatballs.ai.models import Knowledge, KnowledgeCategory
|
||||
from chatballs.i18n import t
|
||||
from chatballs.tenancy.context import TenantContext
|
||||
|
||||
|
||||
@@ -35,14 +36,14 @@ class KnowledgeImportResult:
|
||||
def _title(document: dict[str, object]) -> str:
|
||||
value = document.get("title")
|
||||
if not isinstance(value, str) or not value.strip():
|
||||
raise ValidationError({"title": "Title is required"})
|
||||
raise ValidationError({"title": t("ai.title_required")})
|
||||
return value.strip()
|
||||
|
||||
|
||||
def _content(document: dict[str, object]) -> str:
|
||||
value = document.get("content")
|
||||
if not isinstance(value, str):
|
||||
raise ValidationError({"content": "Content string is required"})
|
||||
raise ValidationError({"content": t("ai.content_required")})
|
||||
return value
|
||||
|
||||
|
||||
@@ -53,18 +54,18 @@ def _description(document: dict[str, object], *, default: str) -> str:
|
||||
if value is None:
|
||||
return ""
|
||||
if not isinstance(value, str):
|
||||
raise ValidationError({"description": "Description must be a string"})
|
||||
raise ValidationError({"description": t("ai.description_string")})
|
||||
return value.strip()
|
||||
|
||||
|
||||
def _category_for_path(*, context: TenantContext, raw_path: object) -> KnowledgeCategory:
|
||||
if not isinstance(raw_path, list) or not raw_path:
|
||||
raise ValidationError({"categoryPath": "Non-empty category path required"})
|
||||
raise ValidationError({"categoryPath": t("ai.category_path_required")})
|
||||
parent_id = None
|
||||
category = None
|
||||
for raw_name in raw_path:
|
||||
if not isinstance(raw_name, str) or not raw_name.strip():
|
||||
raise ValidationError({"categoryPath": "Category names must be non-empty strings"})
|
||||
raise ValidationError({"categoryPath": t("ai.category_names_strings")})
|
||||
try:
|
||||
category = KnowledgeCategory.objects.get(
|
||||
organization_id=context.organization_id,
|
||||
@@ -72,7 +73,7 @@ def _category_for_path(*, context: TenantContext, raw_path: object) -> Knowledge
|
||||
name=raw_name.strip(),
|
||||
)
|
||||
except KnowledgeCategory.DoesNotExist as error:
|
||||
raise ValidationError({"categoryPath": "Category path not found"}) from error
|
||||
raise ValidationError({"categoryPath": t("ai.category_path_not_found")}) from error
|
||||
parent_id = category.id
|
||||
assert category is not None
|
||||
return category
|
||||
@@ -153,7 +154,7 @@ def import_knowledge_documents(
|
||||
title = raw_title.strip() if isinstance(raw_title, str) else ""
|
||||
try:
|
||||
if not isinstance(raw_document, dict):
|
||||
raise ValidationError({"document": "Document must be an object"})
|
||||
raise ValidationError({"document": t("ai.document_object_required")})
|
||||
outcome = _import_document(context=context, document=raw_document)
|
||||
except ValidationError as error:
|
||||
result.failed.append({"title": title, "detail": _validation_detail(error)})
|
||||
|
||||
@@ -2,6 +2,7 @@ from django.core.exceptions import ValidationError
|
||||
from django.db import models
|
||||
|
||||
from chatballs.ai.knowledge_types import UNCATEGORIZED_CATEGORY_NAME
|
||||
from chatballs.i18n import t
|
||||
from chatballs.tenancy.models import TenantRelationModel
|
||||
|
||||
|
||||
@@ -42,23 +43,23 @@ class KnowledgeCategory(TenantRelationModel):
|
||||
super().clean()
|
||||
self.name = self.name.strip()
|
||||
if not self.name:
|
||||
raise ValidationError({"name": "Category name is required"})
|
||||
raise ValidationError({"name": t("ai.category_name_required")})
|
||||
persisted = None
|
||||
if self.pk is not None:
|
||||
persisted = type(self).objects.filter(pk=self.pk).values("is_system").first()
|
||||
if persisted and persisted["is_system"] and not self.is_system:
|
||||
raise ValidationError({"category": "System category is immutable"})
|
||||
raise ValidationError({"category": t("ai.system_category_immutable")})
|
||||
if self.is_system and self.name != UNCATEGORIZED_CATEGORY_NAME:
|
||||
raise ValidationError({"name": "System category name is immutable"})
|
||||
raise ValidationError({"name": t("ai.system_category_name_immutable")})
|
||||
|
||||
ancestor = self.parent
|
||||
visited: set[int] = set()
|
||||
while ancestor is not None:
|
||||
if self.pk is not None and ancestor.pk == self.pk:
|
||||
raise ValidationError({"parent": "Category cycle is not allowed"})
|
||||
raise ValidationError({"parent": t("ai.category_cycle")})
|
||||
if ancestor.pk is not None:
|
||||
if ancestor.pk in visited:
|
||||
raise ValidationError({"parent": "Category cycle is not allowed"})
|
||||
raise ValidationError({"parent": t("ai.category_cycle")})
|
||||
visited.add(ancestor.pk)
|
||||
ancestor = ancestor.parent
|
||||
|
||||
@@ -68,11 +69,11 @@ class KnowledgeCategory(TenantRelationModel):
|
||||
|
||||
def delete(self, *args: object, **kwargs: object):
|
||||
if self.is_system:
|
||||
raise ValidationError({"category": "System category cannot be deleted"})
|
||||
raise ValidationError({"category": t("ai.system_category_undeletable")})
|
||||
if self.children.exists():
|
||||
raise ValidationError({"category": "Category with children cannot be deleted"})
|
||||
raise ValidationError({"category": t("ai.category_with_children")})
|
||||
if self.knowledge_items.exists():
|
||||
raise ValidationError({"category": "Category with knowledge cannot be deleted"})
|
||||
raise ValidationError({"category": t("ai.category_with_knowledge")})
|
||||
return super().delete(*args, **kwargs)
|
||||
|
||||
def __str__(self) -> str:
|
||||
|
||||
@@ -12,6 +12,7 @@ from chatballs.ai.models import (
|
||||
KnowledgeAttachment,
|
||||
KnowledgeCategory,
|
||||
)
|
||||
from chatballs.i18n import t
|
||||
from chatballs.tenancy.context import TenantContext
|
||||
from chatballs.tenancy.storage import adjust_storage_usage
|
||||
from chatballs.tenancy.storage_quota import (
|
||||
@@ -39,13 +40,13 @@ def _knowledge_category(*, context: TenantContext, category_id: int | None) -> K
|
||||
id=category_id,
|
||||
)
|
||||
except KnowledgeCategory.DoesNotExist as error:
|
||||
raise ValidationError({"category": "Category not found"}) from error
|
||||
raise ValidationError({"category": t("ai.category_not_found")}) from error
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def create_knowledge(*, context: TenantContext, data: KnowledgeInput) -> Knowledge:
|
||||
if not data.title.strip():
|
||||
raise ValidationError({"title": "Title is required"})
|
||||
raise ValidationError({"title": t("ai.title_required")})
|
||||
knowledge = Knowledge.objects.create(
|
||||
organization=context.organization,
|
||||
category=_knowledge_category(context=context, category_id=data.category_id),
|
||||
@@ -63,9 +64,9 @@ def update_knowledge(
|
||||
*, context: TenantContext, knowledge: Knowledge, data: KnowledgeInput
|
||||
) -> Knowledge:
|
||||
if knowledge.organization_id != context.organization_id:
|
||||
raise ValidationError({"knowledge": "Knowledge belongs to another organization"})
|
||||
raise ValidationError({"knowledge": t("ai.knowledge_other_organization")})
|
||||
if not data.title.strip():
|
||||
raise ValidationError({"title": "Title is required"})
|
||||
raise ValidationError({"title": t("ai.title_required")})
|
||||
locked = Knowledge.objects.select_for_update().get(pk=knowledge.pk)
|
||||
content_changed = locked.content != data.content
|
||||
locked.title = data.title.strip()
|
||||
@@ -94,7 +95,7 @@ def update_knowledge(
|
||||
|
||||
def delete_knowledge(*, context: TenantContext, knowledge: Knowledge) -> None:
|
||||
if knowledge.organization_id != context.organization_id:
|
||||
raise ValidationError({"knowledge": "Knowledge belongs to another organization"})
|
||||
raise ValidationError({"knowledge": t("ai.knowledge_other_organization")})
|
||||
attachments = list(knowledge.attachments.all())
|
||||
released_bytes = sum(attachment.size for attachment in attachments)
|
||||
for attachment in attachments:
|
||||
@@ -112,12 +113,12 @@ def add_attachment(
|
||||
*, context: TenantContext, knowledge: Knowledge, upload: UploadedFile
|
||||
) -> KnowledgeAttachment:
|
||||
if knowledge.organization_id != context.organization_id:
|
||||
raise ValidationError({"knowledge": "Knowledge belongs to another organization"})
|
||||
raise ValidationError({"knowledge": t("ai.knowledge_other_organization")})
|
||||
original_name = (upload.name or "").strip()
|
||||
if not original_name:
|
||||
raise ValidationError({"file": "File name is required"})
|
||||
raise ValidationError({"file": t("ai.file_name_required")})
|
||||
if upload.size and upload.size > _MAX_ATTACHMENT_BYTES:
|
||||
raise ValidationError({"file": "File is too large (max 25 MB)"})
|
||||
raise ValidationError({"file": t("ai.file_too_large_25")})
|
||||
existing = knowledge.attachments.filter(original_name=original_name).first()
|
||||
existing_size = existing.size if existing is not None else 0
|
||||
data = upload.read()
|
||||
@@ -153,7 +154,7 @@ def add_attachment(
|
||||
|
||||
def delete_attachment(*, context: TenantContext, attachment: KnowledgeAttachment) -> None:
|
||||
if attachment.knowledge.organization_id != context.organization_id:
|
||||
raise ValidationError({"attachment": "Attachment belongs to another organization"})
|
||||
raise ValidationError({"attachment": t("ai.attachment_other_organization")})
|
||||
knowledge = attachment.knowledge
|
||||
released_bytes = attachment.size
|
||||
attachment.file.delete(save=False)
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
"""Язык ответов агента.
|
||||
|
||||
Существующие агенты получают MIRROR — ответ на языке обращения. Для
|
||||
одноязычной установки это ничего не меняет: клиенты пишут на её языке, и
|
||||
агент отвечает так же. Для двуязычной — сразу перестаёт отвечать не на том
|
||||
языке, на котором спросили.
|
||||
"""
|
||||
|
||||
dependencies = [
|
||||
("ai", "0016_remove_credential_mode"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="aiagent",
|
||||
name="answer_language",
|
||||
field=models.CharField(default="MIRROR", max_length=16),
|
||||
),
|
||||
]
|
||||
@@ -14,6 +14,13 @@ DEFAULT_AI_MODEL = "anthropic/claude-sonnet-4.6"
|
||||
|
||||
|
||||
|
||||
class AnswerLanguage(models.TextChoices):
|
||||
"""Режимы поля ``AIAgent.answer_language``, кроме кодов самих языков."""
|
||||
|
||||
MIRROR = "MIRROR", "Как у клиента"
|
||||
ORGANIZATION = "ORGANIZATION", "Язык организации"
|
||||
|
||||
|
||||
class AIAgentStatus(models.TextChoices):
|
||||
|
||||
DRAFT = "DRAFT", "Draft"
|
||||
@@ -369,6 +376,19 @@ class AIAgent(TenantRelationModel):
|
||||
|
||||
model_params = models.JSONField(default=dict, blank=True)
|
||||
|
||||
# Язык ответов клиенту. По умолчанию агент отвечает на языке, на котором
|
||||
# к нему обратились: сигнал точный, лежит прямо в сообщении и не требует
|
||||
# настройки. База знаний при этом остаётся одноязычной — поиск ведёт
|
||||
# семантическая ветка (ai.retrieval), а эмбеддинги кроссязычные.
|
||||
#
|
||||
# Хранится либо режим (MIRROR, ORGANIZATION), либо код языка из
|
||||
# chatballs.i18n.LANGUAGE_CODES — для тех, кому нужен ровно один язык
|
||||
# независимо от того, на каком языке пришло сообщение.
|
||||
answer_language = models.CharField(
|
||||
max_length=16,
|
||||
default=AnswerLanguage.MIRROR,
|
||||
)
|
||||
|
||||
# Инструкции из трёх частей; системный промпт собирается в этом порядке.
|
||||
|
||||
persona = models.TextField(blank=True) # кто он и что он
|
||||
|
||||
@@ -3,6 +3,8 @@ from __future__ import annotations
|
||||
import abc
|
||||
from dataclasses import dataclass
|
||||
|
||||
from chatballs.i18n import t
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ChatMessage:
|
||||
@@ -48,4 +50,4 @@ class LLMProvider(abc.ABC):
|
||||
def transcribe(self, *, audio: bytes, filename: str, content_type: str, model: str) -> str:
|
||||
"""Расшифровка аудио (дизайн-базлайн v2). Реализуется OpenAI-совместимыми
|
||||
адаптерами (POST /audio/transcriptions); остальные явно отказывают."""
|
||||
raise ProviderError(f"Провайдер {self.name} не поддерживает расшифровку аудио")
|
||||
raise ProviderError(t("ai.provider_no_transcription", provider=self.name))
|
||||
@@ -22,31 +22,68 @@ from chatballs.ai.provider.base import (
|
||||
LLMProvider,
|
||||
ProviderError,
|
||||
)
|
||||
from chatballs.i18n import t
|
||||
|
||||
EMBEDDING_DIM = 16
|
||||
KNOWLEDGE_MARKER = "Отвечай только на основе этих знаний:"
|
||||
HANDOFF_TOKEN = "<<HANDOFF>>"
|
||||
HUMAN_REQUEST_WORDS = ("человек", "оператор", "сотрудник", "менеджер", "живой", "жалоб", "верн", "возврат")
|
||||
# Язык демо-агента определяется по письму клиента — тем же правилом, что и у
|
||||
# настоящего агента (AnswerLanguage.MIRROR). Здесь оно грубое, по алфавиту:
|
||||
# провайдер детерминированный и без сети, распознавать язык ему нечем.
|
||||
CYRILLIC = re.compile(r"[а-яё]", re.IGNORECASE)
|
||||
|
||||
# Слова, по которым видно, что клиент просит живого человека.
|
||||
HUMAN_REQUEST_WORDS = {
|
||||
"ru": ("человек", "оператор", "сотрудник", "менеджер", "живой", "жалоб", "верн", "возврат"),
|
||||
"en": ("human", "operator", "agent", "manager", "person", "complain", "refund", "return"),
|
||||
}
|
||||
_WORD = re.compile(r"[а-яёa-z0-9]+", re.IGNORECASE)
|
||||
_HEADER = re.compile(r"^\s*\[[^\]]{1,120}\]\s*")
|
||||
_STOP = {
|
||||
"и", "в", "на", "с", "по", "у", "а", "но", "не", "что", "как", "это", "для", "до", "от",
|
||||
"за", "из", "к", "о", "же", "ли", "бы", "вы", "мы", "я", "он", "она", "они", "мне", "вас",
|
||||
"есть", "можно", "нужно", "хочу", "подскажите", "здравствуйте", "добрый", "день", "the",
|
||||
"ru": {
|
||||
"и", "в", "на", "с", "по", "у", "а", "но", "не", "что", "как", "это", "для", "до", "от",
|
||||
"за", "из", "к", "о", "же", "ли", "бы", "вы", "мы", "я", "он", "она", "они", "мне", "вас",
|
||||
"есть", "можно", "нужно", "хочу", "подскажите", "здравствуйте", "добрый", "день",
|
||||
},
|
||||
"en": {
|
||||
"the", "and", "for", "you", "your", "with", "from", "that", "this", "are", "was", "can",
|
||||
"could", "would", "have", "has", "not", "but", "our", "what", "how", "when", "where",
|
||||
"please", "hello", "hi", "there", "want", "need", "tell", "about", "will", "its",
|
||||
},
|
||||
}
|
||||
|
||||
# Грубая морфология: смысл в том, чтобы «доставка» ≈ «доставку», а
|
||||
# «deliveries» ≈ «delivery». Настоящий стеммер тут был бы зависимостью ради
|
||||
# демо-стенда.
|
||||
_SUFFIXES = {
|
||||
"ru": (
|
||||
"ами", "ями", "ого", "его", "ому", "ему", "ыми", "ими", "ах", "ях", "ов", "ев", "ам",
|
||||
"ям", "ой", "ей", "ую", "юю", "ая", "яя", "ые", "ие", "ть", "ся", "а", "я", "ы", "и",
|
||||
"у", "ю", "е", "о",
|
||||
),
|
||||
"en": ("ing", "ies", "ied", "ers", "es", "ed", "er", "ly", "s"),
|
||||
}
|
||||
|
||||
|
||||
def _tokens(text: str) -> set[str]:
|
||||
def _language_of(text: str) -> str:
|
||||
"""Язык письма клиента: кириллица — русский, иначе английский."""
|
||||
|
||||
return "ru" if CYRILLIC.search(text) else "en"
|
||||
|
||||
|
||||
def _tokens(text: str, language: str) -> set[str]:
|
||||
stop = _STOP[language]
|
||||
return {
|
||||
_stem(word.lower())
|
||||
for word in _WORD.findall(text)
|
||||
if len(word) > 2 and word.lower() not in _STOP
|
||||
if len(word) > 2 and word.lower() not in stop
|
||||
}
|
||||
|
||||
|
||||
def _stem(word: str) -> str:
|
||||
# Грубая морфология: обрезаем частые русские окончания, чтобы «доставка» ≈ «доставку».
|
||||
for suffix in ("ами", "ями", "ого", "его", "ому", "ему", "ыми", "ими", "ах", "ях", "ов", "ев", "ам", "ям", "ой", "ей", "ую", "юю", "ая", "яя", "ые", "ие", "ть", "ся", "а", "я", "ы", "и", "у", "ю", "е", "о"):
|
||||
# Слово может быть на другом языке, чем письмо (знания одноязычные), поэтому
|
||||
# окончания режем по алфавиту самого слова, а не по языку диалога.
|
||||
for suffix in _SUFFIXES[_language_of(word)]:
|
||||
if len(word) > 4 and word.endswith(suffix):
|
||||
return word[: -len(suffix)]
|
||||
return word
|
||||
@@ -85,12 +122,13 @@ def _knowledge_sentences(messages: list[ChatMessage]) -> list[str]:
|
||||
def compose_reply(messages: list[ChatMessage]) -> tuple[str, bool]:
|
||||
"""Ответ по знаниям и признак передачи оператору."""
|
||||
question = next((m.content for m in reversed(messages) if m.role == "user"), "")
|
||||
language = _language_of(question)
|
||||
lowered = question.lower()
|
||||
wants_human = any(word in lowered for word in HUMAN_REQUEST_WORDS)
|
||||
query = _tokens(question)
|
||||
wants_human = any(word in lowered for word in HUMAN_REQUEST_WORDS[language])
|
||||
query = _tokens(question, language)
|
||||
scored = []
|
||||
for index, sentence in enumerate(_knowledge_sentences(messages)):
|
||||
overlap = len(query & _tokens(sentence))
|
||||
overlap = len(query & _tokens(sentence, language))
|
||||
if overlap:
|
||||
scored.append((-overlap, index, sentence))
|
||||
scored.sort()
|
||||
@@ -98,13 +136,8 @@ def compose_reply(messages: list[ChatMessage]) -> tuple[str, bool]:
|
||||
if best and not wants_human:
|
||||
return " ".join(best), False
|
||||
if best:
|
||||
return (
|
||||
" ".join(best)
|
||||
+ " Передаю диалог сотруднику — он поможет дальше."
|
||||
), True
|
||||
return (
|
||||
"Уточню этот вопрос у коллег и передам диалог сотруднику — он ответит в рабочее время."
|
||||
), True
|
||||
return " ".join(best) + t("ai.demo_handover_suffix", language=language), True
|
||||
return t("ai.demo_handover", language=language), True
|
||||
|
||||
|
||||
class DemoProvider(LLMProvider):
|
||||
@@ -131,4 +164,4 @@ class DemoProvider(LLMProvider):
|
||||
]
|
||||
|
||||
def transcribe(self, *, audio: bytes, filename: str, content_type: str, model: str) -> str:
|
||||
raise ProviderError("Расшифровка голосовых недоступна в демо-провайдере: подключите OpenRouter или совместимый провайдер в «Настройках».")
|
||||
raise ProviderError(t("ai.demo_no_transcription"))
|
||||
@@ -4,6 +4,7 @@ from django.core.exceptions import ImproperlyConfigured
|
||||
from chatballs.ai.provider import routing
|
||||
from chatballs.ai.provider.base import LLMProvider, ProviderError
|
||||
from chatballs.ai.provider.local import LocalProvider
|
||||
from chatballs.i18n import t
|
||||
|
||||
|
||||
def _test_provider() -> LLMProvider:
|
||||
@@ -26,6 +27,6 @@ def get_provider(*, channel=None) -> LLMProvider:
|
||||
|
||||
if channel is None:
|
||||
raise ProviderError(
|
||||
"AI-провайдер не настроен: вызов без канала не может выбрать интеграцию"
|
||||
t("ai.provider_not_configured")
|
||||
)
|
||||
return routing.resolve_provider(channel)
|
||||
@@ -38,6 +38,7 @@ import urllib.error
|
||||
import urllib.request
|
||||
|
||||
from chatballs.ai.provider.base import ChatMessage, ChatResult, EmbeddingResult, ProviderError
|
||||
from chatballs.i18n import t
|
||||
from chatballs.integrations.proxy import build_opener
|
||||
|
||||
|
||||
@@ -155,7 +156,7 @@ def chat_completions(*, base_url: str, api_key: str, messages: list[ChatMessage]
|
||||
|
||||
except (KeyError, IndexError, TypeError) as error:
|
||||
|
||||
raise ProviderError(f"Unexpected provider response: {error}") from error
|
||||
raise ProviderError(t("ai.unexpected_provider_response", error=error)) from error
|
||||
|
||||
usage = data.get("usage") or {}
|
||||
|
||||
@@ -195,7 +196,7 @@ def embeddings(*, base_url: str, api_key: str, texts: list[str], model: str,
|
||||
|
||||
except (KeyError, TypeError) as error:
|
||||
|
||||
raise ProviderError(f"Unexpected provider response: {error}") from error
|
||||
raise ProviderError(t("ai.unexpected_provider_response", error=error)) from error
|
||||
|
||||
usage = data.get("usage") or {}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from chatballs.ai.provider import openai_http
|
||||
from chatballs.ai.provider.base import ChatMessage, ChatResult, EmbeddingResult, LLMProvider
|
||||
from chatballs.i18n import t
|
||||
|
||||
|
||||
class OpenRouterProvider(LLMProvider):
|
||||
@@ -65,10 +66,10 @@ class OpenRouterProvider(LLMProvider):
|
||||
payload = json.loads(response.read().decode("utf-8"))
|
||||
except urllib.error.HTTPError as error:
|
||||
detail = error.read().decode("utf-8", "replace")[:300]
|
||||
raise ProviderError(f"Расшифровка не удалась: HTTP {error.code} {detail}") from error
|
||||
raise ProviderError(t("ai.transcription_failed_http", code=error.code, detail=detail)) from error
|
||||
except (urllib.error.URLError, TimeoutError, OSError, json.JSONDecodeError) as error:
|
||||
raise ProviderError(f"Расшифровка не удалась: {error}") from error
|
||||
raise ProviderError(t("ai.transcription_failed", error=error)) from error
|
||||
text = str(payload.get("text") or "").strip()
|
||||
if not text:
|
||||
raise ProviderError("Провайдер вернул пустую расшифровку")
|
||||
raise ProviderError(t("ai.empty_transcript"))
|
||||
return text
|
||||
@@ -25,6 +25,7 @@ from chatballs.ai.provider.base import LLMProvider, ProviderError
|
||||
from chatballs.ai.provider.custom import CustomProvider
|
||||
from chatballs.ai.provider.demo import DemoProvider
|
||||
from chatballs.ai.provider.openrouter import OpenRouterProvider
|
||||
from chatballs.i18n import t
|
||||
from chatballs.integrations.models import Integration, IntegrationProvider
|
||||
|
||||
|
||||
@@ -79,7 +80,7 @@ def _channel_integration(channel) -> Integration:
|
||||
integration = getattr(channel, "provider_integration", None)
|
||||
if integration is None or not integration.secret:
|
||||
raise IntegrationNotConfigured(
|
||||
"Агент не привязан к LLM-интеграции BYOK; выберите провайдера в настройках агента"
|
||||
t("ai.agent_without_llm_integration")
|
||||
)
|
||||
return integration
|
||||
|
||||
@@ -104,5 +105,5 @@ def _provider_from_integration(integration: Integration) -> LLMProvider:
|
||||
proxy_url=integration.config.get("proxy_url", ""),
|
||||
)
|
||||
raise IntegrationNotConfigured(
|
||||
f"Интеграция «{integration.provider}» не является LLM-провайдером BYOK"
|
||||
t("ai.integration_is_not_llm", provider=integration.provider)
|
||||
)
|
||||
@@ -11,6 +11,7 @@ from dataclasses import dataclass
|
||||
|
||||
from django.core.exceptions import ValidationError
|
||||
|
||||
from chatballs.i18n import t
|
||||
from chatballs.integrations.models import (
|
||||
Integration,
|
||||
IntegrationKind,
|
||||
@@ -35,15 +36,23 @@ def configure_agent_provider(
|
||||
id=integration_id,
|
||||
organization_id=context.organization_id,
|
||||
kind=IntegrationKind.LLM_PROVIDER,
|
||||
provider__in=[IntegrationProvider.OPENROUTER, IntegrationProvider.CUSTOM],
|
||||
# DEMO — полноценный провайдер агента, а не заглушка настроек:
|
||||
# на нём работает демо-стенд сразу после установки, без ключей.
|
||||
# Без него любое сохранение агента демо-организации падало на
|
||||
# «Неизвестная интеграция», хотя менялись инструкции, а не провайдер.
|
||||
provider__in=[
|
||||
IntegrationProvider.OPENROUTER,
|
||||
IntegrationProvider.CUSTOM,
|
||||
IntegrationProvider.DEMO,
|
||||
],
|
||||
)
|
||||
except (Integration.DoesNotExist, TypeError, ValueError) as error:
|
||||
raise ValidationError(
|
||||
{"providerIntegrationId": "Unknown OpenRouter or Custom integration"}
|
||||
{"providerIntegrationId": t("ai.unknown_provider_integration")}
|
||||
) from error
|
||||
model = str(integration.config.get("default_model", "")).strip()
|
||||
if not model:
|
||||
raise ValidationError(
|
||||
{"providerIntegrationId": "Integration default model is required"}
|
||||
{"providerIntegrationId": t("ai.integration_model_required")}
|
||||
)
|
||||
return ProviderSelection(model, integration)
|
||||
@@ -5,11 +5,15 @@ from chatballs.ai.agent_knowledge import (
|
||||
runtime_portal_articles_for_agent,
|
||||
)
|
||||
from chatballs.ai.invocation import invoke_chat
|
||||
from chatballs.ai.models import AIAgent, KnowledgeFragment
|
||||
from chatballs.ai.models import AIAgent, AnswerLanguage, KnowledgeFragment
|
||||
from chatballs.ai.provider.base import ChatMessage, ChatResult
|
||||
from chatballs.ai.retrieval import KnowledgeRetriever
|
||||
from chatballs.i18n import LANGUAGES, customer_language, normalize_language
|
||||
from chatballs.support_portals.addressing import article_public_url
|
||||
|
||||
# Название языка — на нём самом: модели так однозначнее, чем «английский».
|
||||
LANGUAGE_LABELS = dict(LANGUAGES)
|
||||
|
||||
# Гард стиля для мессенджеров: гарантирует простой текст вне зависимости от
|
||||
# того, что написано в авторских инструкциях.
|
||||
MESSENGER_STYLE_GUARD = (
|
||||
@@ -31,6 +35,36 @@ HANDOFF_PROTOCOL = (
|
||||
)
|
||||
|
||||
|
||||
# Язык ответа. Директива стоит отдельной строкой и последней среди системных:
|
||||
# промпт написан по-русски и сам по себе тянет ответ в русский язык, а явное
|
||||
# указание это перебивает. Переводить сам промпт не нужно — его читает модель,
|
||||
# а не человек.
|
||||
ANSWER_IN_CUSTOMER_LANGUAGE = (
|
||||
"Отвечай на том языке, на котором написано последнее сообщение клиента. "
|
||||
"Если язык определить не удалось, отвечай на языке предыдущей переписки."
|
||||
)
|
||||
ANSWER_IN_FIXED_LANGUAGE = (
|
||||
"Отвечай всегда на языке «{language}», независимо от того, на каком языке "
|
||||
"написал клиент."
|
||||
)
|
||||
|
||||
|
||||
def answer_language_directive(agent: AIAgent) -> str:
|
||||
"""Строка системного промпта, задающая язык ответа агента."""
|
||||
|
||||
if agent.answer_language == AnswerLanguage.MIRROR:
|
||||
return ANSWER_IN_CUSTOMER_LANGUAGE
|
||||
if agent.answer_language == AnswerLanguage.ORGANIZATION:
|
||||
code = customer_language(agent.channel.organization)
|
||||
else:
|
||||
code = normalize_language(agent.answer_language)
|
||||
if not code:
|
||||
# Код испортили руками или язык убрали из сборки: зеркало клиента
|
||||
# безопаснее молчания — ответ всё равно попадёт в язык обращения.
|
||||
return ANSWER_IN_CUSTOMER_LANGUAGE
|
||||
return ANSWER_IN_FIXED_LANGUAGE.format(language=LANGUAGE_LABELS[code])
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AgentTurnResult:
|
||||
result: ChatResult
|
||||
@@ -102,6 +136,7 @@ def run_agent_turn(
|
||||
messages.append(
|
||||
ChatMessage(role="system", content=MESSENGER_STYLE_GUARD + "\n\n" + HANDOFF_PROTOCOL)
|
||||
)
|
||||
messages.append(ChatMessage(role="system", content=answer_language_directive(agent)))
|
||||
catalog = knowledge_catalog(agent)
|
||||
if catalog:
|
||||
messages.append(ChatMessage(role="system", content=catalog))
|
||||
|
||||
@@ -11,6 +11,7 @@ from chatballs.ai.models import (
|
||||
)
|
||||
from chatballs.ai.provider_selection import configure_agent_provider
|
||||
from chatballs.channels.models import Channel
|
||||
from chatballs.i18n import t
|
||||
from chatballs.tenancy.context import TenantContext
|
||||
|
||||
|
||||
@@ -24,6 +25,7 @@ class AgentInput:
|
||||
persona: str
|
||||
tone: str
|
||||
instructions: str
|
||||
answer_language: str
|
||||
knowledge_ids: list[int] | None # None -> выбор знаний не меняется
|
||||
|
||||
|
||||
@@ -71,7 +73,7 @@ def knowledge_for_agent_ids(
|
||||
.order_by("id")
|
||||
)
|
||||
if len(items) != len(requested_ids):
|
||||
raise ValidationError({"knowledgeIds": "Unknown or unavailable knowledge item"})
|
||||
raise ValidationError({"knowledgeIds": t("ai.knowledge_unknown_or_unavailable")})
|
||||
return items
|
||||
|
||||
|
||||
@@ -79,16 +81,16 @@ def knowledge_for_agent_ids(
|
||||
def create_agent(*, context: TenantContext, data: AgentCreateInput) -> AIAgent:
|
||||
organization = context.organization
|
||||
if not data.channel_code:
|
||||
raise ValidationError({"channel": "Channel is required"})
|
||||
raise ValidationError({"channel": t("ai.channel_required")})
|
||||
try:
|
||||
channel = Channel.objects.select_for_update().get(
|
||||
organization=organization,
|
||||
code=data.channel_code,
|
||||
)
|
||||
except Channel.DoesNotExist as error:
|
||||
raise ValidationError({"channel": "Channel not found"}) from error
|
||||
raise ValidationError({"channel": t("channels.not_found")}) from error
|
||||
if AIAgent.objects.filter(channel=channel).exists():
|
||||
raise ValidationError({"channel": "Channel already has an AI agent"})
|
||||
raise ValidationError({"channel": t("channels.agent_exists")})
|
||||
|
||||
knowledge_items = knowledge_for_agent_ids(
|
||||
context=context,
|
||||
@@ -116,7 +118,7 @@ def create_agent(*, context: TenantContext, data: AgentCreateInput) -> AIAgent:
|
||||
@transaction.atomic
|
||||
def update_agent(*, context: TenantContext, agent: AIAgent, data: AgentInput) -> AIAgent:
|
||||
if agent.channel.organization_id != context.organization_id:
|
||||
raise ValidationError({"agent": "Agent belongs to another organization"})
|
||||
raise ValidationError({"agent": t("ai.agent_other_organization")})
|
||||
channel = Channel.objects.select_for_update().get(
|
||||
id=agent.channel_id,
|
||||
organization_id=context.organization_id,
|
||||
@@ -148,6 +150,7 @@ def update_agent(*, context: TenantContext, agent: AIAgent, data: AgentInput) ->
|
||||
locked.persona = data.persona
|
||||
locked.tone = data.tone
|
||||
locked.instructions = data.instructions
|
||||
locked.answer_language = data.answer_language
|
||||
locked.save(
|
||||
update_fields=[
|
||||
"name",
|
||||
@@ -159,6 +162,7 @@ def update_agent(*, context: TenantContext, agent: AIAgent, data: AgentInput) ->
|
||||
"persona",
|
||||
"tone",
|
||||
"instructions",
|
||||
"answer_language",
|
||||
"updated_at",
|
||||
]
|
||||
)
|
||||
@@ -172,7 +176,7 @@ def set_agent_active(*, context: TenantContext, agent: AIAgent, is_active: bool)
|
||||
"""Смена статуса AI без тарифных слотов (ADR-CHATBALLS-0042 §2): количество
|
||||
активных агентов не ограничено; активация требует настроенного провайдера."""
|
||||
if agent.channel.organization_id != context.organization_id:
|
||||
raise ValidationError({"agent": "Agent belongs to another organization"})
|
||||
raise ValidationError({"agent": t("ai.agent_other_organization")})
|
||||
locked = AIAgent.objects.select_for_update().get(
|
||||
pk=agent.id, organization_id=context.organization_id
|
||||
)
|
||||
@@ -180,10 +184,10 @@ def set_agent_active(*, context: TenantContext, agent: AIAgent, is_active: bool)
|
||||
if locked.status == target_status:
|
||||
return locked
|
||||
if locked.status == AIAgentStatus.ARCHIVED:
|
||||
raise ValidationError({"agent": "Archived AI agent cannot change state"})
|
||||
raise ValidationError({"agent": t("ai.archived_agent_state")})
|
||||
if is_active and locked.provider_integration_id is None:
|
||||
raise ValidationError(
|
||||
{"providerIntegrationId": "Для запуска AI выберите провайдера организации"}
|
||||
{"providerIntegrationId": t("ai.pick_provider_first")}
|
||||
)
|
||||
locked.status = target_status
|
||||
locked.lifecycle_version += 1
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
"""Язык ответов агента: директива системного промпта и её режимы.
|
||||
|
||||
Язык ответа задаётся не переводом промпта, а отдельной строкой: промпт читает
|
||||
модель, и переводить его незачем, — но написан он по-русски и сам по себе
|
||||
тянет ответ в русский язык. Явное указание это перебивает.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from django.test import TestCase
|
||||
|
||||
from chatballs.ai.models import AIAgent, AnswerLanguage
|
||||
from chatballs.ai.runtime import (
|
||||
ANSWER_IN_CUSTOMER_LANGUAGE,
|
||||
answer_language_directive,
|
||||
)
|
||||
from chatballs.channels.models import Channel
|
||||
from chatballs.identity.bootstrap import bootstrap_owner
|
||||
from chatballs.identity.models import Organization
|
||||
|
||||
|
||||
class AnswerLanguageDirectiveTests(TestCase):
|
||||
def setUp(self) -> None:
|
||||
bootstrap_owner(email="owner@example.com", password="temporary-password")
|
||||
self.organization = Organization.objects.get(slug="demo")
|
||||
self.channel = Channel.objects.create(
|
||||
organization=self.organization, code="main", name="Main"
|
||||
)
|
||||
|
||||
def _agent(self, answer_language: str) -> AIAgent:
|
||||
return AIAgent(
|
||||
organization=self.organization,
|
||||
channel=self.channel,
|
||||
name="Agent",
|
||||
answer_language=answer_language,
|
||||
)
|
||||
|
||||
def test_default_is_the_language_of_the_client(self) -> None:
|
||||
self.assertEqual(AIAgent().answer_language, AnswerLanguage.MIRROR)
|
||||
self.assertEqual(
|
||||
answer_language_directive(self._agent(AnswerLanguage.MIRROR)),
|
||||
ANSWER_IN_CUSTOMER_LANGUAGE,
|
||||
)
|
||||
|
||||
def test_fixed_language_names_it_in_the_language_itself(self) -> None:
|
||||
# Название языка на нём самом («English», а не «английский»): модели так
|
||||
# однозначнее, и перевода названия не требуется.
|
||||
directive = answer_language_directive(self._agent("en"))
|
||||
self.assertIn("English", directive)
|
||||
self.assertNotEqual(directive, ANSWER_IN_CUSTOMER_LANGUAGE)
|
||||
|
||||
def test_organization_mode_follows_the_organization(self) -> None:
|
||||
self.organization.language = "en"
|
||||
self.organization.save(update_fields=["language"])
|
||||
directive = answer_language_directive(self._agent(AnswerLanguage.ORGANIZATION))
|
||||
self.assertIn("English", directive)
|
||||
|
||||
def test_unknown_value_falls_back_to_the_client_language(self) -> None:
|
||||
# Значение испортили руками или язык убрали из сборки: зеркало клиента
|
||||
# безопаснее молчания — ответ всё равно попадёт в язык обращения.
|
||||
self.assertEqual(
|
||||
answer_language_directive(self._agent("klingon")),
|
||||
ANSWER_IN_CUSTOMER_LANGUAGE,
|
||||
)
|
||||
@@ -89,3 +89,45 @@ class DemoIntegrationTests(TestCase):
|
||||
checked = run_integration_test(context=self.context, integration=integration)
|
||||
self.assertEqual(checked.status, IntegrationStatus.OK)
|
||||
self.assertIsInstance(_provider_from_integration(Integration.objects.get(pk=integration.pk)), DemoProvider)
|
||||
|
||||
|
||||
ENGLISH_KNOWLEDGE = """Отвечай только на основе этих знаний:
|
||||
Making takes 5-7 working days after payment. Delivery across the country is DPD, \
|
||||
3-5 working days. The London courier calls an hour ahead.
|
||||
Ready-made items can be returned within 14 days."""
|
||||
|
||||
|
||||
class DemoProviderLanguageTests(TestCase):
|
||||
"""Демо-агент отвечает на языке обращения — как настоящий в режиме MIRROR."""
|
||||
|
||||
def test_english_question_is_answered_from_english_knowledge(self) -> None:
|
||||
result = DemoProvider().chat(
|
||||
messages=[
|
||||
ChatMessage(role="system", content=ENGLISH_KNOWLEDGE),
|
||||
ChatMessage(role="user", content="How long does delivery take?"),
|
||||
],
|
||||
model="demo",
|
||||
)
|
||||
self.assertIn("DPD", result.text)
|
||||
self.assertNotIn(HANDOFF_TOKEN, result.text)
|
||||
|
||||
def test_english_request_for_human_hands_off_in_english(self) -> None:
|
||||
result = DemoProvider().chat(
|
||||
messages=[
|
||||
ChatMessage(role="system", content=ENGLISH_KNOWLEDGE),
|
||||
ChatMessage(role="user", content="I want to speak to a human"),
|
||||
],
|
||||
model="demo",
|
||||
)
|
||||
self.assertIn(HANDOFF_TOKEN, result.text)
|
||||
self.assertNotRegex(result.text, r"[А-Яа-яЁё]")
|
||||
|
||||
def test_russian_question_still_answers_in_russian(self) -> None:
|
||||
result = DemoProvider().chat(
|
||||
messages=[
|
||||
ChatMessage(role="system", content=KNOWLEDGE),
|
||||
ChatMessage(role="user", content="Сколько идёт доставка?"),
|
||||
],
|
||||
model="demo",
|
||||
)
|
||||
self.assertIn("СДЭК", result.text)
|
||||
@@ -159,6 +159,28 @@ class AgentProviderOwnershipTests(TestCase):
|
||||
with self.assertRaises(ValidationError):
|
||||
configure_agent_provider(context=self.context, integration_id=999999)
|
||||
|
||||
def test_demo_provider_is_a_valid_agent_provider(self) -> None:
|
||||
"""Регрессия: демо-стенд нельзя было редактировать.
|
||||
|
||||
Демо ставит агентов на встроенный DEMO-провайдер, а выбор провайдера
|
||||
его не принимал. Поле providerIntegrationId уходит с каждым PATCH
|
||||
карточки, поэтому падало любое сохранение агента — даже правка
|
||||
инструкций, где провайдер не меняли.
|
||||
"""
|
||||
|
||||
demo = create_integration(
|
||||
context=self.context,
|
||||
data=IntegrationInput(
|
||||
provider=IntegrationProvider.DEMO,
|
||||
name="Демо-провайдер",
|
||||
secret="",
|
||||
config={},
|
||||
),
|
||||
)
|
||||
selection = configure_agent_provider(context=self.context, integration_id=demo.id)
|
||||
self.assertEqual(selection.integration, demo)
|
||||
self.assertEqual(selection.model, "demo")
|
||||
|
||||
def test_selection_never_writes_to_the_channel(self) -> None:
|
||||
integration = self._integration()
|
||||
selection = configure_agent_provider(
|
||||
|
||||
@@ -30,6 +30,7 @@ from chatballs.ai.selectors import (
|
||||
from chatballs.ai.serializers import attachment_payload, knowledge_payload
|
||||
from chatballs.api.pagination import page_payload, paginate
|
||||
from chatballs.api.permissions import HasCapability
|
||||
from chatballs.i18n import t
|
||||
from chatballs.identity.audit import record_audit_event
|
||||
from chatballs.identity.models import AuditEvent
|
||||
|
||||
@@ -122,7 +123,7 @@ class KnowledgeDetailView(_KnowledgeBaseView):
|
||||
try:
|
||||
knowledge = self._read_knowledge(request, knowledge_id)
|
||||
except Knowledge.DoesNotExist:
|
||||
return Response({"detail": "Knowledge not found"}, status=404)
|
||||
return Response({"detail": t("ai.knowledge_not_found")}, status=404)
|
||||
payload = knowledge_payload(knowledge)
|
||||
created_event = (
|
||||
AuditEvent.objects.filter(
|
||||
@@ -162,7 +163,7 @@ class KnowledgeDetailView(_KnowledgeBaseView):
|
||||
try:
|
||||
knowledge = self._write_knowledge(request, knowledge_id)
|
||||
except Knowledge.DoesNotExist:
|
||||
return Response({"detail": "Knowledge not found"}, status=404)
|
||||
return Response({"detail": t("ai.knowledge_not_found")}, status=404)
|
||||
try:
|
||||
data = knowledge_input(request.data, current=knowledge)
|
||||
if not employee_can_write_knowledge(
|
||||
@@ -185,7 +186,7 @@ class KnowledgeDetailView(_KnowledgeBaseView):
|
||||
try:
|
||||
knowledge = self._write_knowledge(request, knowledge_id)
|
||||
except Knowledge.DoesNotExist:
|
||||
return Response({"detail": "Knowledge not found"}, status=404)
|
||||
return Response({"detail": t("ai.knowledge_not_found")}, status=404)
|
||||
self._audit(request, "deleted", knowledge)
|
||||
delete_knowledge(context=request.tenant_context, knowledge=knowledge)
|
||||
return Response(status=204)
|
||||
@@ -195,7 +196,7 @@ class KnowledgeImportView(_KnowledgeBaseView):
|
||||
def post(self, request: Request) -> Response:
|
||||
documents = request.data.get("documents")
|
||||
if not isinstance(documents, list) or not documents:
|
||||
return Response({"detail": "documents must be a non-empty list"}, status=400)
|
||||
return Response({"detail": t("ai.documents_non_empty")}, status=400)
|
||||
result = import_knowledge_documents(
|
||||
context=request.tenant_context,
|
||||
documents=documents,
|
||||
@@ -222,7 +223,7 @@ class KnowledgeReindexView(_KnowledgeBaseView):
|
||||
try:
|
||||
knowledge = self._write_knowledge(request, knowledge_id)
|
||||
except Knowledge.DoesNotExist:
|
||||
return Response({"detail": "Knowledge not found"}, status=404)
|
||||
return Response({"detail": t("ai.knowledge_not_found")}, status=404)
|
||||
reindex_knowledge(knowledge)
|
||||
self._audit(request, "reindexed", knowledge)
|
||||
knowledge = self._write_knowledge(request, knowledge_id)
|
||||
@@ -236,10 +237,10 @@ class KnowledgeAttachmentUploadView(_KnowledgeBaseView):
|
||||
try:
|
||||
knowledge = self._write_knowledge(request, knowledge_id)
|
||||
except Knowledge.DoesNotExist:
|
||||
return Response({"detail": "Knowledge not found"}, status=404)
|
||||
return Response({"detail": t("ai.knowledge_not_found")}, status=404)
|
||||
upload = request.FILES.get("file")
|
||||
if upload is None:
|
||||
return Response({"detail": "file is required (multipart/form-data)"}, status=400)
|
||||
return Response({"detail": t("ai.file_required")}, status=400)
|
||||
try:
|
||||
attachment = add_attachment(
|
||||
context=request.tenant_context, knowledge=knowledge, upload=upload
|
||||
@@ -255,10 +256,10 @@ class KnowledgeAttachmentDeleteView(_KnowledgeBaseView):
|
||||
try:
|
||||
knowledge = self._write_knowledge(request, knowledge_id)
|
||||
except Knowledge.DoesNotExist:
|
||||
return Response({"detail": "Knowledge not found"}, status=404)
|
||||
return Response({"detail": t("ai.knowledge_not_found")}, status=404)
|
||||
attachment = knowledge.attachments.filter(id=attachment_id).first()
|
||||
if attachment is None:
|
||||
return Response({"detail": "Attachment not found"}, status=404)
|
||||
return Response({"detail": t("ai.attachment_not_found")}, status=404)
|
||||
delete_attachment(context=request.tenant_context, attachment=attachment)
|
||||
self._audit(request, "attachment_deleted", knowledge)
|
||||
return Response(status=204)
|
||||
@@ -25,6 +25,8 @@ from typing import Any
|
||||
from django.db.models import Q, QuerySet
|
||||
from rest_framework.exceptions import ValidationError
|
||||
|
||||
from chatballs.i18n import t
|
||||
|
||||
DEFAULT_PAGE_SIZE = 20
|
||||
MAX_PAGE_SIZE = 100
|
||||
DEFAULT_WINDOW_SIZE = 50
|
||||
@@ -41,9 +43,9 @@ def _bounded_int(params, name: str, default: int, maximum: int) -> int:
|
||||
try:
|
||||
value = int(raw)
|
||||
except (TypeError, ValueError):
|
||||
raise ValidationError(f"{name}: ожидается целое число") from None
|
||||
raise ValidationError(t("api.expected_integer", name=name)) from None
|
||||
if value < 1:
|
||||
raise ValidationError(f"{name}: ожидается число больше нуля")
|
||||
raise ValidationError(t("api.expected_positive", name=name))
|
||||
return min(value, maximum)
|
||||
|
||||
|
||||
@@ -55,9 +57,9 @@ def cursor_id(params, name: str = "cursor") -> int | None:
|
||||
try:
|
||||
value = int(raw)
|
||||
except (TypeError, ValueError):
|
||||
raise ValidationError(f"{name}: ожидается идентификатор записи") from None
|
||||
raise ValidationError(t("api.expected_record_id", name=name)) from None
|
||||
if value < 1:
|
||||
raise ValidationError(f"{name}: ожидается идентификатор записи")
|
||||
raise ValidationError(t("api.expected_record_id", name=name))
|
||||
return value
|
||||
|
||||
|
||||
@@ -192,7 +194,7 @@ def window(
|
||||
выпадать из ленты или повторяться на границе окна.
|
||||
"""
|
||||
if not keys:
|
||||
raise ValueError("окно требует хотя бы один ключ сортировки")
|
||||
raise ValueError(t("api.window_needs_sort_key"))
|
||||
ordered = queryset.order_by(*[key.ordering for key in keys])
|
||||
if after is not None:
|
||||
condition = _after_cursor(ordered, keys, after)
|
||||
|
||||
@@ -15,6 +15,7 @@ from chatballs.calls.services import CALL_INVITE_SEND
|
||||
from chatballs.calls.tokens import issue_invite_token
|
||||
from chatballs.conversations import transports
|
||||
from chatballs.events.handlers import register
|
||||
from chatballs.i18n import customer_language, t
|
||||
from chatballs.identity.instance_settings import public_base_url
|
||||
from chatballs.tenancy.context import TenantContext
|
||||
|
||||
@@ -49,8 +50,11 @@ def handle_call_invite_send(payload: dict, context: TenantContext | None) -> Non
|
||||
token, token_hash = issue_invite_token()
|
||||
invite.token_hash = token_hash
|
||||
invite.save(update_fields=["token_hash"])
|
||||
call_label = "аудиозвонок" if call.kind == "AUDIO" else "видеозвонок"
|
||||
invite_text = f"Приглашаем вас на {call_label}. Нажмите кнопку, чтобы перейти к звонку."
|
||||
# Приглашение читает клиент, а не оператор, и рождается оно в воркере,
|
||||
# где запроса нет: язык берём у организации.
|
||||
language = customer_language(call.conversation.organization)
|
||||
call_label = t("calls.kind_audio" if call.kind == "AUDIO" else "calls.kind_video", language=language)
|
||||
invite_text = t("calls.invite_text", kind=call_label, language=language)
|
||||
url = f"{public_base_url()}/calls/{token}?kind={call.kind}"
|
||||
sent = transports.send_call_invite(
|
||||
call.delivery_connection,
|
||||
|
||||
@@ -11,7 +11,8 @@ from chatballs.calls.models import (
|
||||
CallStatus,
|
||||
ParticipantSide,
|
||||
)
|
||||
from chatballs.conversations.models import Message, MessageAuthor
|
||||
from chatballs.conversations.models import Message, MessageAuthor, SystemEvent
|
||||
from chatballs.i18n import t
|
||||
|
||||
ALLOWED_TRANSITIONS = {
|
||||
CallStatus.REQUESTED: {
|
||||
@@ -39,6 +40,29 @@ def _format_duration(seconds: int) -> str:
|
||||
return f"{seconds // 60:02d}:{seconds % 60:02d}"
|
||||
|
||||
|
||||
# Код события и его параметры: интерфейс собирает фразу на языке читателя, а
|
||||
# текст остаётся в базе запасным вариантом и читаемой записью.
|
||||
_TIMELINE_EVENTS: dict[str, str] = {
|
||||
CallStatus.ACCEPTED: SystemEvent.CALL_ACCEPTED,
|
||||
CallStatus.DECLINED: SystemEvent.CALL_DECLINED,
|
||||
CallStatus.CANCELLED: SystemEvent.CALL_CANCELLED,
|
||||
CallStatus.MISSED: SystemEvent.CALL_MISSED,
|
||||
CallStatus.EXPIRED: SystemEvent.CALL_EXPIRED,
|
||||
CallStatus.ACTIVE: SystemEvent.CALL_STARTED,
|
||||
CallStatus.ENDED: SystemEvent.CALL_ENDED,
|
||||
CallStatus.FAILED: SystemEvent.CALL_FAILED,
|
||||
}
|
||||
|
||||
|
||||
def _timeline_event(call: CallSession, target_status: str) -> tuple[str, dict]:
|
||||
"""Код события и параметры для строки таймлайна."""
|
||||
|
||||
code = _TIMELINE_EVENTS.get(target_status, "")
|
||||
if code == SystemEvent.CALL_ENDED and call.duration_seconds is not None:
|
||||
return code, {"duration": _format_duration(call.duration_seconds)}
|
||||
return code, {}
|
||||
|
||||
|
||||
def _timeline_text(call: CallSession, target_status: str) -> str | None:
|
||||
# Системные события звонка в timeline диалога (SPEC-CHATBALLS-0013 §13).
|
||||
# Вызывается только при фактической смене статуса — retry дублей не даёт.
|
||||
@@ -76,10 +100,12 @@ def transition_call(
|
||||
if target_status == call.status:
|
||||
return call
|
||||
if target_status not in ALLOWED_TRANSITIONS.get(call.status, set()):
|
||||
raise CallInvalidTransition(f"Переход {call.status} -> {target_status} запрещён")
|
||||
raise CallInvalidTransition(
|
||||
t("calls.transition_forbidden", current=call.status, target=target_status)
|
||||
)
|
||||
normalized_failure_code = failure_code.strip()
|
||||
if target_status == CallStatus.FAILED and not FAILURE_CODE_PATTERN.fullmatch(normalized_failure_code):
|
||||
raise CallInvalidTransition("Для FAILED требуется нормализованный failure_code")
|
||||
raise CallInvalidTransition(t("calls.failure_code_required"))
|
||||
|
||||
now = timezone.now()
|
||||
update_fields = ["status", "updated_at"]
|
||||
@@ -106,9 +132,12 @@ def transition_call(
|
||||
call.save(update_fields=update_fields)
|
||||
timeline_text = _timeline_text(call, target_status)
|
||||
if timeline_text is not None:
|
||||
event, params = _timeline_event(call, target_status)
|
||||
Message.objects.create(
|
||||
conversation_id=call.conversation_id,
|
||||
author_type=MessageAuthor.SYSTEM,
|
||||
system_event=event,
|
||||
system_params=params,
|
||||
text=timeline_text,
|
||||
)
|
||||
return call
|
||||
|
||||
@@ -3,6 +3,7 @@ import uuid
|
||||
from django.conf import settings
|
||||
from django.db import models
|
||||
|
||||
from chatballs.i18n import t
|
||||
from chatballs.tenancy.models import TenantRelationModel
|
||||
|
||||
|
||||
@@ -40,7 +41,7 @@ TERMINAL_CALL_STATUSES = (
|
||||
class CallEndedBy(models.TextChoices):
|
||||
STAFF = "STAFF", "Сотрудник"
|
||||
CUSTOMER = "CUSTOMER", "Клиент"
|
||||
SYSTEM = "SYSTEM", "Система"
|
||||
SYSTEM = "SYSTEM", t("admin.actor_system")
|
||||
TIMEOUT = "TIMEOUT", "Таймаут"
|
||||
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from chatballs.calls.errors import CallAccessDenied
|
||||
from chatballs.calls.models import CallSession
|
||||
from chatballs.conversations.models import Conversation
|
||||
from chatballs.i18n import t
|
||||
from chatballs.identity.models import OrganizationMembership
|
||||
from chatballs.identity.policy import require_capability
|
||||
from chatballs.tenancy.context import TenantContext
|
||||
@@ -8,11 +9,11 @@ from chatballs.tenancy.context import TenantContext
|
||||
|
||||
def ensure_conversation_call_access(*, user, conversation: Conversation) -> None:
|
||||
if not require_capability(user, "conversations.call", conversation):
|
||||
raise CallAccessDenied("Нет доступа к звонкам")
|
||||
raise CallAccessDenied(t("calls.no_access"))
|
||||
from chatballs.conversations.selectors import conversation_is_visible
|
||||
|
||||
if not conversation_is_visible(actor=user, conversation=conversation):
|
||||
raise CallAccessDenied("Диалог вне групп сотрудника")
|
||||
raise CallAccessDenied(t("calls.conversation_outside_groups"))
|
||||
|
||||
|
||||
def ensure_call_access(*, user, call_session: CallSession) -> None:
|
||||
|
||||
@@ -26,10 +26,12 @@ from chatballs.calls.tokens import (
|
||||
issue_call_access_token,
|
||||
verify_call_access_token,
|
||||
)
|
||||
from chatballs.identity.models import Organization, OrganizationMembership
|
||||
from chatballs.i18n import t
|
||||
from chatballs.identity.models import OrganizationMembership
|
||||
from chatballs.tenancy.context import TenantContext
|
||||
from chatballs.tenancy.database import tenant_atomic
|
||||
from chatballs.tenancy.ingress import call_invite_route, call_session_route
|
||||
from chatballs.tenancy.lookup import load_organization
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -39,15 +41,14 @@ class ResolvedInvite:
|
||||
|
||||
|
||||
def resolve_invite(*, token: str) -> ResolvedInvite:
|
||||
message = "Недействительное или истёкшее приглашение"
|
||||
message = t("calls.invite_invalid")
|
||||
token_hash = hash_invite_token(token)
|
||||
route = call_invite_route(token_hash)
|
||||
if route is None:
|
||||
raise CallTokenError(message)
|
||||
try:
|
||||
organization = Organization.objects.get(pk=route.organization_id)
|
||||
except Organization.DoesNotExist:
|
||||
raise CallTokenError(message) from None
|
||||
organization = load_organization(route.organization_id)
|
||||
if organization is None:
|
||||
raise CallTokenError(message)
|
||||
context = TenantContext.for_resource(organization)
|
||||
with tenant_atomic(context):
|
||||
invite = (
|
||||
@@ -90,11 +91,10 @@ def _authorize_call_access(
|
||||
claims = verify_call_access_token(token)
|
||||
route = call_session_route(str(claims.call_session_id))
|
||||
if route is None:
|
||||
raise CallTokenError("Недействительный или истёкший call access token")
|
||||
try:
|
||||
organization = Organization.objects.get(pk=route.organization_id)
|
||||
except Organization.DoesNotExist:
|
||||
raise CallTokenError("Недействительный или истёкший call access token") from None
|
||||
raise CallTokenError(t("calls.token_invalid"))
|
||||
organization = load_organization(route.organization_id)
|
||||
if organization is None:
|
||||
raise CallTokenError(t("calls.token_invalid"))
|
||||
resource_context = TenantContext.for_resource(organization)
|
||||
with tenant_atomic(resource_context):
|
||||
try:
|
||||
@@ -102,9 +102,9 @@ def _authorize_call_access(
|
||||
"conversation", "conversation__channel", "initiated_by", "organization"
|
||||
).get(id=claims.call_session_id, organization=organization)
|
||||
except CallSession.DoesNotExist:
|
||||
raise CallTokenError("Недействительный или истёкший call access token") from None
|
||||
raise CallTokenError(t("calls.token_invalid")) from None
|
||||
if call.status in TERMINAL_CALL_STATUSES and not allow_terminal:
|
||||
raise CallTokenError("Звонок уже завершён")
|
||||
raise CallTokenError(t("calls.already_ended"))
|
||||
membership = None
|
||||
if claims.side == ParticipantSide.STAFF:
|
||||
participant = call.participants.select_related("user").filter(
|
||||
@@ -133,7 +133,7 @@ def _authorize_call_access(
|
||||
or call.status not in {CallStatus.REQUESTED, CallStatus.RINGING}
|
||||
)
|
||||
if not valid:
|
||||
raise CallTokenError("Недействительный или истёкший call access token")
|
||||
raise CallTokenError(t("calls.token_invalid"))
|
||||
context = (
|
||||
TenantContext.for_membership(membership)
|
||||
if membership is not None
|
||||
@@ -170,7 +170,7 @@ def _customer_call(
|
||||
allow_terminal=allow_terminal,
|
||||
)
|
||||
if claims.side != ParticipantSide.CUSTOMER:
|
||||
raise CallTokenError("Недействительный или истёкший call access token")
|
||||
raise CallTokenError(t("calls.token_invalid"))
|
||||
return call, context
|
||||
|
||||
|
||||
@@ -186,7 +186,7 @@ def accept_call_by_access_token(*, token: str) -> CallSession:
|
||||
organization=context.organization,
|
||||
)
|
||||
except CallInvalidTransition as error:
|
||||
raise CallConflict("Приглашение уже нельзя принять") from error
|
||||
raise CallConflict(t("calls.invite_cannot_accept")) from error
|
||||
|
||||
|
||||
def decline_call_by_access_token(*, token: str) -> CallSession:
|
||||
@@ -205,7 +205,7 @@ def decline_call_by_access_token(*, token: str) -> CallSession:
|
||||
organization=context.organization,
|
||||
)
|
||||
except CallInvalidTransition as error:
|
||||
raise CallConflict("Приглашение уже нельзя отклонить") from error
|
||||
raise CallConflict(t("calls.invite_cannot_decline")) from error
|
||||
|
||||
|
||||
def end_call_by_access_token(*, token: str) -> CallSession:
|
||||
|
||||
@@ -49,9 +49,11 @@ from chatballs.conversations.models import (
|
||||
LifecycleState,
|
||||
Message,
|
||||
MessageAuthor,
|
||||
SystemEvent,
|
||||
)
|
||||
from chatballs.conversations.services import ClaimError, claim_locked_conversation
|
||||
from chatballs.events.services import DomainEvent, enqueue_event
|
||||
from chatballs.i18n import t
|
||||
from chatballs.integrations.features import call_allowed
|
||||
from chatballs.integrations.models import IntegrationProvider
|
||||
from chatballs.tenancy.context import TenantContext
|
||||
@@ -80,7 +82,7 @@ class CreatedCall:
|
||||
|
||||
def _conversation_identity(conversation: Conversation) -> ConnectionIdentity:
|
||||
if conversation.contact_id is None or conversation.connection_id is None:
|
||||
raise CallConflict("У диалога нет клиентской identity для звонка")
|
||||
raise CallConflict(t("calls.no_client_identity"))
|
||||
identities = list(
|
||||
ConnectionIdentity.objects.filter(
|
||||
contact_id=conversation.contact_id,
|
||||
@@ -89,29 +91,29 @@ def _conversation_identity(conversation: Conversation) -> ConnectionIdentity:
|
||||
.order_by("id")[:2]
|
||||
)
|
||||
if len(identities) != 1:
|
||||
raise CallConflict("Клиентская identity звонка отсутствует или неоднозначна")
|
||||
raise CallConflict(t("calls.identity_missing_or_ambiguous"))
|
||||
return identities[0]
|
||||
|
||||
|
||||
def _check_call_creation_conflicts(*, conversation: Conversation, initiator) -> None:
|
||||
if conversation.lifecycle != LifecycleState.OPEN:
|
||||
raise CallConflict("Звонок можно запросить только в открытом диалоге")
|
||||
raise CallConflict(t("calls.only_in_open_conversation"))
|
||||
if CallSession.objects.filter(
|
||||
conversation=conversation,
|
||||
status__in=UNFINISHED_CALL_STATUSES,
|
||||
).exists():
|
||||
raise CallConflict("В диалоге уже есть незавершённый звонок")
|
||||
raise CallConflict(t("calls.unfinished_call_exists"))
|
||||
if CallSession.objects.filter(
|
||||
initiated_by=initiator,
|
||||
status__in=UNFINISHED_CALL_STATUSES,
|
||||
).exists():
|
||||
raise CallConflict("Сотрудник уже участвует в другом звонке")
|
||||
raise CallConflict(t("calls.operator_busy"))
|
||||
if (
|
||||
conversation.control_mode == ControlMode.HUMAN
|
||||
and conversation.assigned_operator_id
|
||||
and conversation.assigned_operator_id != initiator.id
|
||||
):
|
||||
raise CallConflict("Диалог уже ведёт другой оператор")
|
||||
raise CallConflict(t("calls.conversation_taken"))
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
@@ -120,7 +122,7 @@ def create_call_request(
|
||||
) -> CreatedCall:
|
||||
initiator = context.actor_user
|
||||
if initiator is None or context.membership is None:
|
||||
raise CallAccessDenied("Для звонка требуется контекст сотрудника")
|
||||
raise CallAccessDenied(t("calls.operator_context_required"))
|
||||
conversation = (
|
||||
Conversation.objects.select_for_update()
|
||||
.select_related("channel")
|
||||
@@ -129,7 +131,7 @@ def create_call_request(
|
||||
ensure_conversation_call_access(user=context.membership, conversation=conversation)
|
||||
if not call_allowed(conversation.connection, kind):
|
||||
raise CallAccessDenied(
|
||||
"Видеозвонки отключены для этой точки входа" if kind == CallKind.VIDEO else "Звонки отключены для этой точки входа"
|
||||
t("calls.video_off_entry_point") if kind == CallKind.VIDEO else t("calls.calls_off_entry_point")
|
||||
)
|
||||
identity = _conversation_identity(conversation)
|
||||
_check_call_creation_conflicts(conversation=conversation, initiator=initiator)
|
||||
@@ -151,7 +153,7 @@ def create_call_request(
|
||||
kind=kind,
|
||||
)
|
||||
except IntegrityError as error:
|
||||
raise CallConflict("Не удалось создать второй незавершённый звонок") from error
|
||||
raise CallConflict(t("calls.second_call_rejected")) from error
|
||||
|
||||
CallInvite.objects.create(
|
||||
organization=context.organization,
|
||||
@@ -181,6 +183,10 @@ def create_call_request(
|
||||
Message.objects.create(
|
||||
conversation=conversation,
|
||||
author_type=MessageAuthor.SYSTEM,
|
||||
system_event=SystemEvent.CALL_REQUESTED,
|
||||
# Вид звонка — параметр, а не часть кода: фразу собирает интерфейс, и
|
||||
# «аудио» против «видео» там отдельным словом словаря.
|
||||
system_params={"operator": initiator_label, "kind": call.kind},
|
||||
text=f"Оператор {initiator_label} запросил {call_word}",
|
||||
)
|
||||
if conversation.connection.provider == IntegrationProvider.WEB:
|
||||
@@ -210,13 +216,13 @@ def create_call_request(
|
||||
def issue_staff_access_token(*, context: TenantContext, call_session: CallSession) -> str:
|
||||
user = context.actor_user
|
||||
if user is None or context.membership is None:
|
||||
raise CallAccessDenied("Для звонка требуется контекст сотрудника")
|
||||
raise CallAccessDenied(t("calls.operator_context_required"))
|
||||
ensure_call_access(user=context.membership, call_session=call_session)
|
||||
participant_exists = call_session.participants.filter(side=ParticipantSide.STAFF, user=user).exists()
|
||||
if not participant_exists:
|
||||
raise CallConflict("Сотрудник не является участником звонка")
|
||||
raise CallConflict(t("calls.not_a_participant"))
|
||||
if call_session.status in TERMINAL_CALL_STATUSES:
|
||||
raise CallConflict("Звонок уже завершён")
|
||||
raise CallConflict(t("calls.already_ended"))
|
||||
return issue_call_access_token(
|
||||
call_session_id=call_session.id,
|
||||
side=ParticipantSide.STAFF,
|
||||
@@ -227,10 +233,10 @@ def issue_staff_access_token(*, context: TenantContext, call_session: CallSessio
|
||||
def cancel_call(*, context: TenantContext, call_session: CallSession) -> CallSession:
|
||||
user = context.actor_user
|
||||
if user is None or context.membership is None:
|
||||
raise CallAccessDenied("Для звонка требуется контекст сотрудника")
|
||||
raise CallAccessDenied(t("calls.operator_context_required"))
|
||||
ensure_call_access(user=context.membership, call_session=call_session)
|
||||
if not call_session.participants.filter(side=ParticipantSide.STAFF, user=user).exists():
|
||||
raise CallConflict("Сотрудник не является участником звонка")
|
||||
raise CallConflict(t("calls.not_a_participant"))
|
||||
try:
|
||||
# Повторная отмена идемпотентна: transition_call вернёт звонок без изменений.
|
||||
return transition_call(
|
||||
@@ -239,7 +245,7 @@ def cancel_call(*, context: TenantContext, call_session: CallSession) -> CallSes
|
||||
ended_by=CallEndedBy.STAFF,
|
||||
)
|
||||
except CallInvalidTransition as error:
|
||||
raise CallConflict("Звонок уже нельзя отменить") from error
|
||||
raise CallConflict(t("calls.cannot_cancel")) from error
|
||||
|
||||
|
||||
def active_call_for_conversation(conversation: Conversation) -> CallSession | None:
|
||||
@@ -272,10 +278,10 @@ def webchat_active_call(identity: ConnectionIdentity) -> CallSession | None:
|
||||
def open_call_for_identity(*, identity: ConnectionIdentity) -> ResolvedInvite:
|
||||
call = webchat_active_call(identity)
|
||||
if call is None:
|
||||
raise CallTokenError("Активное приглашение не найдено")
|
||||
raise CallTokenError(t("webchat.no_active_invite"))
|
||||
invite = CallInvite.objects.select_for_update().get(call_session=call)
|
||||
if invite.expires_at <= timezone.now():
|
||||
raise CallTokenError("Активное приглашение не найдено")
|
||||
raise CallTokenError(t("webchat.no_active_invite"))
|
||||
if invite.opened_at is None:
|
||||
invite.opened_at = timezone.now()
|
||||
invite.save(update_fields=["opened_at"])
|
||||
@@ -290,7 +296,7 @@ def open_call_for_identity(*, identity: ConnectionIdentity) -> ResolvedInvite:
|
||||
def decline_call_for_identity(*, identity: ConnectionIdentity) -> CallSession:
|
||||
call = webchat_active_call(identity)
|
||||
if call is None:
|
||||
raise CallTokenError("Активное приглашение не найдено")
|
||||
raise CallTokenError(t("webchat.no_active_invite"))
|
||||
try:
|
||||
return transition_call(
|
||||
call_session_id=call.id,
|
||||
@@ -298,4 +304,4 @@ def decline_call_for_identity(*, identity: ConnectionIdentity) -> CallSession:
|
||||
ended_by=CallEndedBy.CUSTOMER,
|
||||
)
|
||||
except CallInvalidTransition as error:
|
||||
raise CallConflict("Приглашение уже нельзя отклонить") from error
|
||||
raise CallConflict(t("calls.invite_cannot_decline")) from error
|
||||
@@ -14,6 +14,7 @@ from django.conf import settings
|
||||
|
||||
from chatballs.calls.errors import CallTokenError
|
||||
from chatballs.calls.models import ParticipantSide
|
||||
from chatballs.i18n import t
|
||||
|
||||
ACCESS_TOKEN_VERSION = 1
|
||||
ACCESS_TOKEN_PURPOSE = "call-access"
|
||||
@@ -82,7 +83,7 @@ def issue_call_access_token(*, call_session_id: uuid.UUID, side: str, subject_id
|
||||
|
||||
|
||||
def verify_call_access_token(token: str) -> CallAccessClaims:
|
||||
message = "Недействительный или истёкший call access token"
|
||||
message = t("calls.token_invalid")
|
||||
if not token or "." not in token:
|
||||
raise CallTokenError(message)
|
||||
encoded, signature = token.rsplit(".", 1)
|
||||
|
||||
@@ -25,6 +25,7 @@ from chatballs.calls.services import (
|
||||
resolve_invite,
|
||||
)
|
||||
from chatballs.conversations.models import Conversation
|
||||
from chatballs.i18n import t
|
||||
from chatballs.identity.audit import record_audit_event
|
||||
|
||||
|
||||
@@ -49,16 +50,16 @@ class CallCreateView(APIView):
|
||||
|
||||
def post(self, request: Request, conversation_id: int) -> Response:
|
||||
if "kind" not in request.data:
|
||||
return Response({"detail": "Укажите тип звонка"}, status=400)
|
||||
return Response({"detail": t("calls.kind_required")}, status=400)
|
||||
kind = str(request.data.get("kind", ""))
|
||||
if kind not in CallKind.values:
|
||||
return Response({"detail": "Недопустимый тип звонка"}, status=400)
|
||||
return Response({"detail": t("calls.kind_invalid")}, status=400)
|
||||
try:
|
||||
created = create_call_request(
|
||||
context=request.tenant_context, conversation_id=conversation_id, kind=kind
|
||||
)
|
||||
except Conversation.DoesNotExist:
|
||||
return Response({"detail": "Диалог не найден"}, status=404)
|
||||
return Response({"detail": t("conversations.not_found")}, status=404)
|
||||
except CallAccessDenied as error:
|
||||
return Response({"detail": str(error)}, status=403)
|
||||
except CallConflict as error:
|
||||
@@ -95,7 +96,7 @@ class CallDetailView(APIView):
|
||||
id=call_session_id, organization=request.tenant_context.organization
|
||||
)
|
||||
except CallSession.DoesNotExist:
|
||||
return Response({"detail": "Звонок не найден"}, status=404)
|
||||
return Response({"detail": t("calls.not_found")}, status=404)
|
||||
try:
|
||||
ensure_call_access(user=request.tenant_context.membership, call_session=call)
|
||||
except CallAccessDenied as error:
|
||||
@@ -113,7 +114,7 @@ class StaffAccessTokenView(APIView):
|
||||
)
|
||||
token = issue_staff_access_token(context=request.tenant_context, call_session=call)
|
||||
except CallSession.DoesNotExist:
|
||||
return Response({"detail": "Звонок не найден"}, status=404)
|
||||
return Response({"detail": t("calls.not_found")}, status=404)
|
||||
except CallAccessDenied as error:
|
||||
return Response({"detail": str(error)}, status=403)
|
||||
except CallConflict as error:
|
||||
@@ -131,7 +132,7 @@ class CallCancelView(APIView):
|
||||
)
|
||||
cancel_call(context=request.tenant_context, call_session=call)
|
||||
except CallSession.DoesNotExist:
|
||||
return Response({"detail": "Звонок не найден"}, status=404)
|
||||
return Response({"detail": t("calls.not_found")}, status=404)
|
||||
except CallAccessDenied as error:
|
||||
return Response({"detail": str(error)}, status=403)
|
||||
except CallConflict as error:
|
||||
@@ -163,7 +164,7 @@ class ConversationActiveCallView(APIView):
|
||||
user=request.tenant_context.membership, conversation=conversation
|
||||
)
|
||||
except Conversation.DoesNotExist:
|
||||
return Response({"detail": "Диалог не найден"}, status=404)
|
||||
return Response({"detail": t("conversations.not_found")}, status=404)
|
||||
except CallAccessDenied as error:
|
||||
return Response({"detail": str(error)}, status=403)
|
||||
call = active_call_for_conversation(conversation)
|
||||
@@ -181,7 +182,7 @@ class InviteResolveView(APIView):
|
||||
try:
|
||||
resolved = resolve_invite(token=token)
|
||||
except CallTokenError:
|
||||
return Response({"detail": "Недействительное или истёкшее приглашение"}, status=404)
|
||||
return Response({"detail": t("calls.invite_invalid")}, status=404)
|
||||
return _token_response(
|
||||
{
|
||||
"call": public_invite_payload(
|
||||
@@ -214,7 +215,7 @@ class CallAccessStateView(_CallAccessView):
|
||||
try:
|
||||
call = call_state_by_access_token(token=_bearer_token(request))
|
||||
except CallTokenError:
|
||||
return Response({"detail": "Недействительный или истёкший call access token"}, status=404)
|
||||
return Response({"detail": t("calls.token_invalid")}, status=404)
|
||||
return _token_response(
|
||||
{"call": public_call_state_payload(call), "iceServers": ice_servers_payload()}
|
||||
)
|
||||
@@ -225,7 +226,7 @@ class CallAccessAcceptView(_CallAccessView):
|
||||
try:
|
||||
call = accept_call_by_access_token(token=_bearer_token(request))
|
||||
except CallTokenError:
|
||||
return Response({"detail": "Недействительный или истёкший call access token"}, status=404)
|
||||
return Response({"detail": t("calls.token_invalid")}, status=404)
|
||||
except CallConflict as error:
|
||||
return Response({"detail": str(error)}, status=409)
|
||||
return _token_response({"call": public_call_state_payload(call)})
|
||||
@@ -236,7 +237,7 @@ class CallAccessDeclineView(_CallAccessView):
|
||||
try:
|
||||
call = decline_call_by_access_token(token=_bearer_token(request))
|
||||
except CallTokenError:
|
||||
return Response({"detail": "Недействительный или истёкший call access token"}, status=404)
|
||||
return Response({"detail": t("calls.token_invalid")}, status=404)
|
||||
except CallConflict as error:
|
||||
return Response({"detail": str(error)}, status=409)
|
||||
return _token_response({"call": public_call_state_payload(call)})
|
||||
@@ -247,5 +248,5 @@ class CallAccessEndView(_CallAccessView):
|
||||
try:
|
||||
call = end_call_by_access_token(token=_bearer_token(request))
|
||||
except CallTokenError:
|
||||
return Response({"detail": "Недействительный или истёкший call access token"}, status=404)
|
||||
return Response({"detail": t("calls.token_invalid")}, status=404)
|
||||
return _token_response({"call": public_call_state_payload(call)})
|
||||
@@ -9,6 +9,7 @@ from __future__ import annotations
|
||||
|
||||
from django.core.exceptions import PermissionDenied
|
||||
|
||||
from chatballs.i18n import t
|
||||
from chatballs.identity.policy import has_capability_any_scope
|
||||
from chatballs.tenancy.context import TenantContext
|
||||
|
||||
@@ -32,15 +33,17 @@ def has_organization_capability(context: TenantContext, capability: str) -> bool
|
||||
|
||||
|
||||
def require_organization_manage(context: TenantContext, *, operation: str) -> None:
|
||||
"""``operation`` — ключ каталога, а не готовый текст: отказ читает человек."""
|
||||
|
||||
if not has_organization_capability(context, CHANNELS_MANAGE):
|
||||
raise PermissionDenied(f"{operation} требует channels.manage")
|
||||
raise PermissionDenied(t("channels.operation_needs_manage", operation=t(operation)))
|
||||
|
||||
|
||||
def require_channel_manage(context: TenantContext) -> None:
|
||||
if not has_organization_capability(context, CHANNELS_MANAGE):
|
||||
raise PermissionDenied("Нет прав на изменение канала")
|
||||
raise PermissionDenied(t("channels.no_rights_to_change"))
|
||||
|
||||
|
||||
def require_connections_manage(context: TenantContext) -> None:
|
||||
if not has_organization_capability(context, INTEGRATIONS_MANAGE):
|
||||
raise PermissionDenied("Привязка подключения требует integrations.manage")
|
||||
raise PermissionDenied(t("channels.binding_needs_manage"))
|
||||
@@ -1,5 +1,6 @@
|
||||
from chatballs.ai.provider.base import ChatResult, ProviderError
|
||||
from chatballs.ai.runtime import run_agent_turn
|
||||
from chatballs.i18n import t
|
||||
|
||||
|
||||
def run_channel_turn(*, channel, message: str, history: list[dict] | None = None) -> ChatResult:
|
||||
@@ -11,5 +12,5 @@ def run_channel_turn(*, channel, message: str, history: list[dict] | None = None
|
||||
"""
|
||||
agent = getattr(channel, "ai_agent", None)
|
||||
if agent is None or not agent.is_active:
|
||||
raise ProviderError("Channel has no active AI agent")
|
||||
raise ProviderError(t("channels.no_active_agent"))
|
||||
return run_agent_turn(agent=agent, message=message, history=history, style_guard=True).result
|
||||
@@ -10,6 +10,7 @@ from django.db import transaction
|
||||
|
||||
from chatballs.channels import authorization
|
||||
from chatballs.channels.models import Channel
|
||||
from chatballs.i18n import t
|
||||
from chatballs.identity.group_models import EmployeeGroup
|
||||
from chatballs.integrations.models import Integration, IntegrationKind
|
||||
from chatballs.tenancy.context import TenantContext
|
||||
@@ -34,7 +35,7 @@ class ChannelHasReferences(Exception):
|
||||
|
||||
def __init__(self, blockers: list[dict[str, Any]]) -> None:
|
||||
self.blockers = blockers
|
||||
super().__init__("Канал нельзя удалить: есть связанные записи")
|
||||
super().__init__(t("channels.cannot_delete_linked"))
|
||||
|
||||
def payload(self) -> dict[str, Any]:
|
||||
return {"detail": str(self), "blockers": self.blockers}
|
||||
@@ -46,7 +47,7 @@ class ConnectionAlreadyBound(Exception):
|
||||
def __init__(self, *, integration: Integration) -> None:
|
||||
self.integration = integration
|
||||
super().__init__(
|
||||
"Подключение уже привязано к другому каналу: перенос требует force"
|
||||
t("channels.binding_conflict")
|
||||
)
|
||||
|
||||
def payload(self) -> dict[str, Any]:
|
||||
@@ -73,9 +74,9 @@ class ChannelUpdate:
|
||||
def _clean_name(raw: object) -> str:
|
||||
name = str(raw or "").strip()
|
||||
if not name:
|
||||
raise ValidationError({"name": "Название канала не может быть пустым"})
|
||||
raise ValidationError({"name": t("channels.name_required")})
|
||||
if len(name) > NAME_MAX_LENGTH:
|
||||
raise ValidationError({"name": "Название канала длиннее 255 символов"})
|
||||
raise ValidationError({"name": t("channels.name_too_long")})
|
||||
return name
|
||||
|
||||
|
||||
@@ -90,7 +91,7 @@ def _group_for_channel(
|
||||
organization_id=context.organization_id,
|
||||
)
|
||||
except EmployeeGroup.DoesNotExist as error:
|
||||
raise ValidationError({"groupId": "Unknown group"}) from error
|
||||
raise ValidationError({"groupId": t("channels.unknown_group")}) from error
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
@@ -113,11 +114,11 @@ def update_channel(
|
||||
authorization.require_channel_manage(context)
|
||||
if update.is_active is not UNSET and update.is_active != locked.is_active:
|
||||
authorization.require_organization_manage(
|
||||
context, operation="Изменение статуса канала"
|
||||
context, operation="channels.operation_status_change"
|
||||
)
|
||||
if update.policy:
|
||||
authorization.require_organization_manage(
|
||||
context, operation="Изменение политики канала"
|
||||
context, operation="channels.operation_policy_change"
|
||||
)
|
||||
|
||||
changed: list[str] = []
|
||||
@@ -148,10 +149,10 @@ def _messenger_integration(*, context: TenantContext, integration_id: int) -> In
|
||||
id=integration_id, organization_id=context.organization_id
|
||||
)
|
||||
except Integration.DoesNotExist as error:
|
||||
raise ValidationError({"integrationId": "Подключение не найдено"}) from error
|
||||
raise ValidationError({"integrationId": t("channels.connection_not_found")}) from error
|
||||
if integration.kind != IntegrationKind.MESSENGER:
|
||||
raise ValidationError(
|
||||
{"integrationId": "LLM-провайдер не является подключением канала"}
|
||||
{"integrationId": t("channels.llm_is_not_a_connection")}
|
||||
)
|
||||
return integration
|
||||
|
||||
@@ -168,7 +169,7 @@ def bind_connection(
|
||||
authorization.require_connections_manage(context)
|
||||
if not channel.is_active:
|
||||
raise ValidationError(
|
||||
{"channelId": "Нельзя привязать подключение к архивному каналу"}
|
||||
{"channelId": t("channels.archived_channel_binding")}
|
||||
)
|
||||
integration = _messenger_integration(context=context, integration_id=integration_id)
|
||||
previous_channel_id = integration.channel_id
|
||||
@@ -188,7 +189,7 @@ def unbind_connection(
|
||||
authorization.require_connections_manage(context)
|
||||
integration = _messenger_integration(context=context, integration_id=integration_id)
|
||||
if integration.channel_id != channel.id:
|
||||
raise ValidationError({"integrationId": "Подключение не привязано к каналу"})
|
||||
raise ValidationError({"integrationId": t("channels.connection_not_bound")})
|
||||
# Диалоги не затрагиваются: Conversation.connection объявлен PROTECT.
|
||||
integration.channel = None
|
||||
integration.save(update_fields=["channel", "updated_at"])
|
||||
|
||||
@@ -28,6 +28,7 @@ from chatballs.conversations.serializers import message_payload
|
||||
from chatballs.conversations.transports.base import guess_content_type, safe_filename
|
||||
from chatballs.conversations.view_base import ConversationViewBase
|
||||
from chatballs.conversations.voice_views import _visible_message
|
||||
from chatballs.i18n import t
|
||||
|
||||
MAX_FILE_BYTES = 20 * 1024 * 1024
|
||||
# Исполняемые и скриптовые типы в чат не отправляем ни в одну сторону.
|
||||
@@ -38,12 +39,12 @@ INLINE_TYPES = ("image/jpeg", "image/png", "image/gif", "image/webp", "applicati
|
||||
def validate_upload(upload) -> str:
|
||||
"""Причина отказа или пустая строка."""
|
||||
if upload.size > MAX_FILE_BYTES:
|
||||
return "Файл больше 20 МБ"
|
||||
return t("conversations.attachment_too_large")
|
||||
if not upload.size:
|
||||
return "Пустой файл"
|
||||
return t("conversations.attachment_empty")
|
||||
name = safe_filename(upload.name or "")
|
||||
if name.lower().endswith(BLOCKED_SUFFIXES):
|
||||
return "Такой тип файла отправить нельзя"
|
||||
return t("conversations.attachment_type_blocked")
|
||||
return ""
|
||||
|
||||
|
||||
@@ -62,9 +63,9 @@ class MessageAttachmentView(ConversationViewBase):
|
||||
try:
|
||||
message = _visible_message(request, message_id)
|
||||
except (Message.DoesNotExist, Conversation.DoesNotExist):
|
||||
return Response({"detail": "Сообщение не найдено"}, status=404)
|
||||
return Response({"detail": t("conversations.message_not_found")}, status=404)
|
||||
if message.kind != MessageKind.FILE or not message.attachment:
|
||||
return Response({"detail": "Файл недоступен"}, status=404)
|
||||
return Response({"detail": t("conversations.file_unavailable")}, status=404)
|
||||
return attachment_response(message, inline="inline" in request.GET)
|
||||
|
||||
|
||||
@@ -80,15 +81,15 @@ class ConversationAttachmentView(ConversationViewBase):
|
||||
try:
|
||||
conversation = self._conversation(request, conversation_id, self.required_capability)
|
||||
except Conversation.DoesNotExist:
|
||||
return Response({"detail": "Диалог не найден"}, status=404)
|
||||
return Response({"detail": t("conversations.not_found")}, status=404)
|
||||
if conversation.lifecycle != LifecycleState.OPEN:
|
||||
return Response({"detail": "Диалог закрыт"}, status=409)
|
||||
return Response({"detail": t("conversations.closed")}, status=409)
|
||||
connection = conversation.connection
|
||||
if connection is None or not transports.supports_file_send(connection):
|
||||
return Response({"detail": "Файлы недоступны в этом канале"}, status=400)
|
||||
return Response({"detail": t("conversations.files_unavailable_channel")}, status=400)
|
||||
upload = request.FILES.get("file")
|
||||
if upload is None:
|
||||
return Response({"detail": "Прикрепите файл"}, status=400)
|
||||
return Response({"detail": t("conversations.attach_file")}, status=400)
|
||||
problem = validate_upload(upload)
|
||||
if problem:
|
||||
return Response({"detail": problem}, status=400)
|
||||
@@ -113,7 +114,7 @@ class ConversationAttachmentView(ConversationViewBase):
|
||||
caption=caption,
|
||||
)
|
||||
if not sent:
|
||||
return Response({"detail": "Не удалось отправить файл в канал"}, status=502)
|
||||
return Response({"detail": t("conversations.file_send_failed")}, status=502)
|
||||
message = Message.objects.create(
|
||||
conversation=conversation,
|
||||
author_type=MessageAuthor.OPERATOR,
|
||||
|
||||
@@ -25,6 +25,7 @@ from chatballs.conversations.models import (
|
||||
from chatballs.conversations.selectors import apply_conversation_visibility
|
||||
from chatballs.conversations.serializers import conversation_payload
|
||||
from chatballs.conversations.view_base import ConversationViewBase
|
||||
from chatballs.i18n import t
|
||||
from chatballs.identity.avatars import user_avatar_url
|
||||
from chatballs.identity.group_models import EmployeeGroup
|
||||
from chatballs.identity.models import HumanUser, OrganizationMembership
|
||||
@@ -53,10 +54,10 @@ class ConversationPriorityView(ConversationViewBase):
|
||||
request, conversation_id, self.required_capability
|
||||
)
|
||||
except Conversation.DoesNotExist:
|
||||
return Response({"detail": "Диалог не найден"}, status=404)
|
||||
return Response({"detail": t("conversations.not_found")}, status=404)
|
||||
priority = str(request.data.get("priority", "")).strip().upper()
|
||||
if priority not in ConversationPriority.values:
|
||||
return Response({"detail": "Неизвестный приоритет"}, status=400)
|
||||
return Response({"detail": t("conversations.unknown_priority")}, status=400)
|
||||
conversation.priority = priority
|
||||
conversation.save(update_fields=["priority"])
|
||||
self._audit(request, "priority_changed", conversation)
|
||||
@@ -82,19 +83,19 @@ class ConversationContactView(ConversationViewBase):
|
||||
request, conversation_id, self.required_capability
|
||||
)
|
||||
except Conversation.DoesNotExist:
|
||||
return Response({"detail": "Диалог не найден"}, status=404)
|
||||
return Response({"detail": t("conversations.not_found")}, status=404)
|
||||
contact = conversation.contact
|
||||
if contact is None:
|
||||
return Response({"detail": "У диалога нет контакта"}, status=400)
|
||||
return Response({"detail": t("conversations.no_contact")}, status=400)
|
||||
changed: list[str] = []
|
||||
for field, limit in self.LIMITS.items():
|
||||
if field not in request.data:
|
||||
continue
|
||||
value = str(request.data.get(field) or "").strip()
|
||||
if len(value) > limit:
|
||||
return Response({"detail": f"Поле {field}: не длиннее {limit} символов"}, status=400)
|
||||
return Response({"detail": t("conversations.field_too_long", field=field, limit=limit)}, status=400)
|
||||
if field == "name" and not value:
|
||||
return Response({"detail": "Имя контакта не может быть пустым"}, status=400)
|
||||
return Response({"detail": t("conversations.contact_name_empty")}, status=400)
|
||||
setattr(contact, field, value)
|
||||
changed.append(field)
|
||||
if changed:
|
||||
@@ -118,10 +119,10 @@ class ConversationNoteView(ConversationViewBase):
|
||||
request, conversation_id, self.required_capability
|
||||
)
|
||||
except Conversation.DoesNotExist:
|
||||
return Response({"detail": "Диалог не найден"}, status=404)
|
||||
return Response({"detail": t("conversations.not_found")}, status=404)
|
||||
note = str(request.data.get("note", ""))
|
||||
if len(note) > 4000:
|
||||
return Response({"detail": "Заметка длиннее 4000 символов"}, status=400)
|
||||
return Response({"detail": t("conversations.note_too_long")}, status=400)
|
||||
conversation.note = note
|
||||
conversation.note_author = request.user if note else None
|
||||
conversation.note_updated_at = timezone.now() if note else None
|
||||
@@ -146,12 +147,12 @@ class ConversationLabelsView(ConversationViewBase):
|
||||
request, conversation_id, self.required_capability
|
||||
)
|
||||
except Conversation.DoesNotExist:
|
||||
return Response({"detail": "Диалог не найден"}, status=404)
|
||||
return Response({"detail": t("conversations.not_found")}, status=404)
|
||||
label_ids = request.data.get("labelIds")
|
||||
if not isinstance(label_ids, list) or not all(
|
||||
isinstance(item, int) for item in label_ids
|
||||
):
|
||||
return Response({"detail": "labelIds must be a list of ids"}, status=400)
|
||||
return Response({"detail": t("conversations.label_ids_list")}, status=400)
|
||||
labels = list(
|
||||
ConversationLabel.objects.filter(
|
||||
organization_id=request.tenant_context.organization_id,
|
||||
@@ -159,7 +160,7 @@ class ConversationLabelsView(ConversationViewBase):
|
||||
)
|
||||
)
|
||||
if len(labels) != len(set(label_ids)):
|
||||
return Response({"detail": "Неизвестная метка"}, status=400)
|
||||
return Response({"detail": t("conversations.unknown_label")}, status=400)
|
||||
conversation.labels.set(labels)
|
||||
return Response(
|
||||
{
|
||||
@@ -182,13 +183,13 @@ class ConversationArchiveView(ConversationViewBase):
|
||||
request, conversation_id, self.required_capability
|
||||
)
|
||||
except Conversation.DoesNotExist:
|
||||
return Response({"detail": "Диалог не найден"}, status=404)
|
||||
return Response({"detail": t("conversations.not_found")}, status=404)
|
||||
archived = request.data.get("archived")
|
||||
if not isinstance(archived, bool):
|
||||
return Response({"detail": "archived must be a boolean"}, status=400)
|
||||
return Response({"detail": t("conversations.archived_boolean")}, status=400)
|
||||
if not archived and not can_administer_access(request.tenant_context.membership):
|
||||
return Response(
|
||||
{"detail": "Восстановить диалог может только администратор"}, status=403
|
||||
{"detail": t("conversations.restore_admin_only")}, status=403
|
||||
)
|
||||
conversation.archived_at = timezone.now() if archived else None
|
||||
conversation.save(update_fields=["archived_at"])
|
||||
@@ -336,9 +337,9 @@ class LabelListView(APIView):
|
||||
name = str(request.data.get("name", "")).strip()
|
||||
color = str(request.data.get("color", "")).strip()
|
||||
if not name or len(name) > 60:
|
||||
return Response({"detail": "Название метки: 1-60 символов"}, status=400)
|
||||
return Response({"detail": t("conversations.label_name_length")}, status=400)
|
||||
if len(color) > 20:
|
||||
return Response({"detail": "Некорректный цвет"}, status=400)
|
||||
return Response({"detail": t("conversations.invalid_colour")}, status=400)
|
||||
existing = ConversationLabel.objects.filter(
|
||||
organization_id=request.tenant_context.organization_id, name__iexact=name
|
||||
).first()
|
||||
@@ -365,16 +366,16 @@ class LabelDetailView(APIView):
|
||||
try:
|
||||
label = self._label(request, label_id)
|
||||
except ConversationLabel.DoesNotExist:
|
||||
return Response({"detail": "Метка не найдена"}, status=404)
|
||||
return Response({"detail": t("conversations.label_not_found")}, status=404)
|
||||
if "name" in request.data:
|
||||
name = str(request.data.get("name", "")).strip()
|
||||
if not name or len(name) > 60:
|
||||
return Response({"detail": "Название метки: 1-60 символов"}, status=400)
|
||||
return Response({"detail": t("conversations.label_name_length")}, status=400)
|
||||
label.name = name
|
||||
if "color" in request.data:
|
||||
color = str(request.data.get("color", "")).strip()
|
||||
if len(color) > 20:
|
||||
return Response({"detail": "Некорректный цвет"}, status=400)
|
||||
return Response({"detail": t("conversations.invalid_colour")}, status=400)
|
||||
label.color = color
|
||||
label.save()
|
||||
return Response({"label": _label_payload(label)})
|
||||
@@ -383,7 +384,7 @@ class LabelDetailView(APIView):
|
||||
try:
|
||||
label = self._label(request, label_id)
|
||||
except ConversationLabel.DoesNotExist:
|
||||
return Response({"detail": "Метка не найдена"}, status=404)
|
||||
return Response({"detail": t("conversations.label_not_found")}, status=404)
|
||||
label.delete()
|
||||
return Response(status=204)
|
||||
|
||||
@@ -405,13 +406,13 @@ class ReplyTemplateListView(APIView):
|
||||
title = str(request.data.get("title", "")).strip()
|
||||
text = str(request.data.get("text", "")).strip()
|
||||
if not title or len(title) > 120:
|
||||
return Response({"detail": "Название шаблона: 1-120 символов"}, status=400)
|
||||
return Response({"detail": t("conversations.template_name_length")}, status=400)
|
||||
if not text:
|
||||
return Response({"detail": "Текст шаблона обязателен"}, status=400)
|
||||
return Response({"detail": t("conversations.template_text_required")}, status=400)
|
||||
if ReplyTemplate.objects.filter(
|
||||
organization_id=request.tenant_context.organization_id, title__iexact=title
|
||||
).exists():
|
||||
return Response({"detail": "Шаблон с таким названием уже есть"}, status=409)
|
||||
return Response({"detail": t("conversations.template_name_taken")}, status=409)
|
||||
template = ReplyTemplate.objects.create(
|
||||
organization_id=request.tenant_context.organization_id,
|
||||
title=title,
|
||||
@@ -433,16 +434,16 @@ class ReplyTemplateDetailView(APIView):
|
||||
try:
|
||||
template = self._template(request, template_id)
|
||||
except ReplyTemplate.DoesNotExist:
|
||||
return Response({"detail": "Шаблон не найден"}, status=404)
|
||||
return Response({"detail": t("conversations.template_not_found")}, status=404)
|
||||
if "title" in request.data:
|
||||
title = str(request.data.get("title", "")).strip()
|
||||
if not title or len(title) > 120:
|
||||
return Response({"detail": "Название шаблона: 1-120 символов"}, status=400)
|
||||
return Response({"detail": t("conversations.template_name_length")}, status=400)
|
||||
template.title = title
|
||||
if "text" in request.data:
|
||||
text = str(request.data.get("text", "")).strip()
|
||||
if not text:
|
||||
return Response({"detail": "Текст шаблона обязателен"}, status=400)
|
||||
return Response({"detail": t("conversations.template_text_required")}, status=400)
|
||||
template.text = text
|
||||
template.save()
|
||||
return Response({"template": _template_payload(template)})
|
||||
@@ -451,6 +452,6 @@ class ReplyTemplateDetailView(APIView):
|
||||
try:
|
||||
template = self._template(request, template_id)
|
||||
except ReplyTemplate.DoesNotExist:
|
||||
return Response({"detail": "Шаблон не найден"}, status=404)
|
||||
return Response({"detail": t("conversations.template_not_found")}, status=404)
|
||||
template.delete()
|
||||
return Response(status=204)
|
||||
@@ -26,10 +26,11 @@ from chatballs.conversations.models import (
|
||||
Conversation,
|
||||
LifecycleState,
|
||||
)
|
||||
from chatballs.i18n import t
|
||||
from chatballs.identity.audit_catalog import (
|
||||
AUDIT_RESULT_LABELS,
|
||||
audit_action_label,
|
||||
audit_object_label,
|
||||
audit_result_label,
|
||||
)
|
||||
from chatballs.identity.avatars import user_avatar_url_in
|
||||
from chatballs.identity.models import AuditEvent
|
||||
@@ -154,7 +155,11 @@ def client_row(contact: Contact) -> dict:
|
||||
return {
|
||||
"id": contact.id,
|
||||
"cid": f"CUS-{contact.id}",
|
||||
"name": contact.name or "Гость",
|
||||
"name": contact.name or t("conversations.guest"),
|
||||
# Признак анонимного посетителя: интерфейс красит его аватар иначе.
|
||||
# Раньше он выводился из самой подписи регуляркой по слову «Гость» —
|
||||
# на другом языке это перестало бы работать.
|
||||
"isGuest": not contact.name,
|
||||
"phone": contact.phone,
|
||||
"avatarUrl": contact.avatar_url,
|
||||
"email": next(
|
||||
@@ -181,7 +186,12 @@ def client_row(contact: Contact) -> dict:
|
||||
|
||||
|
||||
def _dialog_status(conversation: Conversation) -> str:
|
||||
return {"closed": "Закрыт", "operator": "Оператор", "ai": "AI", "wait": "Ждёт оператора"}[_mode(conversation)]
|
||||
return {
|
||||
"closed": t("conversations.dialog_status_closed"),
|
||||
"operator": t("conversations.dialog_status_operator"),
|
||||
"ai": "AI",
|
||||
"wait": t("conversations.dialog_status_wait"),
|
||||
}[_mode(conversation)]
|
||||
|
||||
|
||||
def client_detail(organization_id: int, contact_id: int) -> dict:
|
||||
@@ -252,9 +262,9 @@ def client_detail(organization_id: int, contact_id: int) -> dict:
|
||||
# Активность из жизненного цикла диалогов (created/closed) — реальные события.
|
||||
activity: list[dict] = []
|
||||
for conversation in conversations:
|
||||
activity.append({"type": "created", "title": f"Диалог · {conversation.channel.name}", "at": conversation.created_at.isoformat()})
|
||||
activity.append({"type": "created", "title": t("conversations.activity_started", channel=conversation.channel.name), "at": conversation.created_at.isoformat()})
|
||||
if conversation.lifecycle == LifecycleState.CLOSED:
|
||||
activity.append({"type": "closed", "title": f"Диалог закрыт · {conversation.channel.name}", "at": conversation.last_activity_at.isoformat()})
|
||||
activity.append({"type": "closed", "title": t("conversations.activity_closed", channel=conversation.channel.name), "at": conversation.last_activity_at.isoformat()})
|
||||
activity.sort(key=lambda item: item["at"], reverse=True)
|
||||
|
||||
conversation_ids = [str(conversation.id) for conversation in conversations]
|
||||
@@ -277,15 +287,19 @@ def client_detail(organization_id: int, contact_id: int) -> dict:
|
||||
# тогда показываем код — как в журнале.
|
||||
"action": audit_action_label(event.action) or event.action,
|
||||
"object": audit_object_label(event.object_type, event.object_id),
|
||||
"actor": (event.actor.full_name or event.actor.email) if event.actor_id else "Система",
|
||||
"result": AUDIT_RESULT_LABELS.get(event.result, "Неизвестно"),
|
||||
"actor": (event.actor.full_name or event.actor.email) if event.actor_id else t("admin.actor_system"),
|
||||
"result": audit_result_label(event.result),
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
"id": contact.id,
|
||||
"cid": f"CUS-{contact.id}",
|
||||
"name": contact.name or "Гость",
|
||||
"name": contact.name or t("conversations.guest"),
|
||||
# Признак анонимного посетителя: интерфейс красит его аватар иначе.
|
||||
# Раньше он выводился из самой подписи регуляркой по слову «Гость» —
|
||||
# на другом языке это перестало бы работать.
|
||||
"isGuest": not contact.name,
|
||||
"phone": contact.phone,
|
||||
"avatarUrl": contact.avatar_url,
|
||||
# Поля карточки из чата (описание, компания, город).
|
||||
@@ -325,7 +339,7 @@ def _merges(organization_id: int, contact: Contact) -> list[dict]:
|
||||
{
|
||||
"id": row.id,
|
||||
"sourceId": row.source_id,
|
||||
"sourceName": row.source.name or "Гость",
|
||||
"sourceName": row.source.name or t("conversations.guest"),
|
||||
"sourceCid": f"CUS-{row.source_id}",
|
||||
"reason": row.reason,
|
||||
"actor": _actor_name(row.actor),
|
||||
@@ -354,7 +368,8 @@ def _duplicate_candidate(organization_id: int, contact: Contact) -> dict | None:
|
||||
return {
|
||||
"id": other.id,
|
||||
"cid": f"CUS-{other.id}",
|
||||
"name": other.name or "Гость",
|
||||
"name": other.name or t("conversations.guest"),
|
||||
"isGuest": not other.name,
|
||||
"avatarUrl": other.avatar_url,
|
||||
"dialogs": other.conversations.count(),
|
||||
"sources": sorted({identity.connection.provider for identity in identities}),
|
||||
|
||||
@@ -22,8 +22,9 @@ from channels.generic.websocket import AsyncJsonWebsocketConsumer
|
||||
from chatballs.conversations.models import Conversation
|
||||
from chatballs.conversations.realtime import conversation_group, inbox_group
|
||||
from chatballs.conversations.selectors import conversation_is_visible
|
||||
from chatballs.identity.models import Organization, OrganizationMembership
|
||||
from chatballs.identity.models import OrganizationMembership
|
||||
from chatballs.tenancy.database import tenant_atomic
|
||||
from chatballs.tenancy.lookup import organization_by_public_id
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -82,8 +83,10 @@ class ConversationEventsConsumer(AsyncJsonWebsocketConsumer):
|
||||
@database_sync_to_async
|
||||
def _membership(self, user_id: int, raw_public_id: str) -> tuple[int, int] | None:
|
||||
try:
|
||||
organization = Organization.objects.get(public_id=uuid.UUID(str(raw_public_id)))
|
||||
except (ValueError, Organization.DoesNotExist):
|
||||
organization = organization_by_public_id(uuid.UUID(str(raw_public_id)))
|
||||
except ValueError:
|
||||
return None
|
||||
if organization is None:
|
||||
return None
|
||||
with tenant_atomic(organization.pk):
|
||||
membership = (
|
||||
|
||||
@@ -13,6 +13,7 @@ from django.db import transaction
|
||||
from django.utils import timezone
|
||||
|
||||
from chatballs.conversations.models import ConnectionIdentity, Contact, ContactMerge, Conversation
|
||||
from chatballs.i18n import t
|
||||
from chatballs.identity.audit import record_audit_event
|
||||
|
||||
# Поля карточки, которые дозаполняются из исходного контакта, если у целевого
|
||||
@@ -24,7 +25,7 @@ MIN_REASON_LENGTH = 5
|
||||
def _clean_reason(reason: str) -> str:
|
||||
cleaned = (reason or "").strip()
|
||||
if len(cleaned) < MIN_REASON_LENGTH:
|
||||
raise ValidationError("Укажите причину объединения — она попадёт в журнал действий")
|
||||
raise ValidationError(t("sales.merge_reason_required"))
|
||||
return cleaned[:2000]
|
||||
|
||||
|
||||
@@ -33,14 +34,14 @@ def merge_contacts(*, organization, target_id: int, source_id: int, reason: str,
|
||||
"""Перенести идентичности и диалоги source в target."""
|
||||
cleaned = _clean_reason(reason)
|
||||
if target_id == source_id:
|
||||
raise ValidationError("Нельзя объединить контакт с самим собой")
|
||||
raise ValidationError(t("sales.merge_self"))
|
||||
try:
|
||||
target = Contact.objects.select_for_update().get(organization=organization, id=target_id)
|
||||
source = Contact.objects.select_for_update().get(organization=organization, id=source_id)
|
||||
except Contact.DoesNotExist as error:
|
||||
raise ValidationError("Контакт не найден") from error
|
||||
raise ValidationError(t("sales.contact_not_found")) from error
|
||||
if source.merged_into_id is not None or target.merged_into_id is not None:
|
||||
raise ValidationError("Контакт уже объединён с другим — сначала разъедините")
|
||||
raise ValidationError(t("sales.already_merged"))
|
||||
|
||||
identity_ids = list(ConnectionIdentity.objects.filter(contact=source).values_list("id", flat=True))
|
||||
conversation_ids = list(
|
||||
@@ -94,9 +95,9 @@ def revert_merge(*, organization, merge_id: int, reason: str, actor, request=Non
|
||||
try:
|
||||
merge = ContactMerge.objects.select_for_update().get(organization=organization, id=merge_id)
|
||||
except ContactMerge.DoesNotExist as error:
|
||||
raise ValidationError("Объединение не найдено") from error
|
||||
raise ValidationError(t("sales.merge_not_found")) from error
|
||||
if merge.reverted_at is not None:
|
||||
raise ValidationError("Это объединение уже разъединено")
|
||||
raise ValidationError(t("sales.already_unmerged"))
|
||||
|
||||
source = merge.source
|
||||
target = merge.target
|
||||
|
||||
@@ -28,10 +28,12 @@ from chatballs.conversations.models import (
|
||||
Message,
|
||||
MessageAuthor,
|
||||
MessageKind,
|
||||
SystemEvent,
|
||||
TranscriptStatus,
|
||||
)
|
||||
from chatballs.conversations.transports.base import InboundMessage
|
||||
from chatballs.events.models import EventOwnership, InboxEvent
|
||||
from chatballs.i18n import t
|
||||
from chatballs.notifications.models import NotificationAudience, NotificationType
|
||||
from chatballs.notifications.services import notify, notify_management
|
||||
from chatballs.tenancy.context import TenantContext
|
||||
@@ -303,6 +305,14 @@ def ingest_inbound(integration, inbound: InboundMessage) -> None:
|
||||
audience=NotificationAudience.OPERATORS,
|
||||
title=f"Новый диалог · {channel.name}",
|
||||
body=f"{contact.name or 'Гость'} · {integration.provider}: {message_text[:80]}",
|
||||
title_key="notifications.new_dialog",
|
||||
body_key="notifications.new_dialog_body",
|
||||
text_params={
|
||||
"channel": channel.name,
|
||||
"contact": contact.name or t("conversations.guest"),
|
||||
"provider": integration.provider,
|
||||
"preview": message_text[:80],
|
||||
},
|
||||
target_id=conversation.id,
|
||||
source_type="Conversation",
|
||||
source_id=conversation.id,
|
||||
@@ -318,6 +328,8 @@ def ingest_inbound(integration, inbound: InboundMessage) -> None:
|
||||
recipient_user=operator,
|
||||
title=f"Новое сообщение · {contact.name or 'Гость'}",
|
||||
body=message_text[:120],
|
||||
title_key="notifications.new_message",
|
||||
text_params={"contact": contact.name or t("conversations.guest")},
|
||||
target_id=conversation.id,
|
||||
source_type="Conversation",
|
||||
source_id=conversation.id,
|
||||
@@ -352,7 +364,10 @@ def ingest_inbound(integration, inbound: InboundMessage) -> None:
|
||||
type=NotificationType.DIALOG_WAITING,
|
||||
audience=NotificationAudience.OPERATORS,
|
||||
title=f"Нужен оператор · {contact.name or 'Гость'}",
|
||||
title_key="notifications.operator_needed",
|
||||
text_params={"contact": contact.name or t("conversations.guest")},
|
||||
body="Голосовое без расшифровки" if is_voice else message_text[:120],
|
||||
body_key="notifications.voice_without_transcript" if is_voice else "",
|
||||
target_id=conversation.id,
|
||||
source_type="Conversation",
|
||||
source_id=conversation.id,
|
||||
@@ -376,7 +391,12 @@ def ingest_inbound(integration, inbound: InboundMessage) -> None:
|
||||
conversation.expected_responder = ExpectedResponder.OPERATOR
|
||||
conversation.last_activity_at = timezone.now()
|
||||
conversation.save(update_fields=["control_mode", "expected_responder", "last_activity_at"])
|
||||
Message.objects.create(conversation=conversation, author_type=MessageAuthor.SYSTEM, text="AI недоступен — диалог передан оператору")
|
||||
Message.objects.create(
|
||||
conversation=conversation,
|
||||
author_type=MessageAuthor.SYSTEM,
|
||||
system_event=SystemEvent.AI_UNAVAILABLE,
|
||||
text="AI недоступен — диалог передан оператору",
|
||||
)
|
||||
fallback = "Извините, прямо сейчас не получается ответить. Я передал ваш вопрос специалисту — он скоро подключится."
|
||||
Message.objects.create(conversation=conversation, author_type=MessageAuthor.AI, text=fallback)
|
||||
notify(
|
||||
@@ -384,7 +404,10 @@ def ingest_inbound(integration, inbound: InboundMessage) -> None:
|
||||
type=NotificationType.DIALOG_WAITING,
|
||||
audience=NotificationAudience.OPERATORS,
|
||||
title=f"Нужен оператор · {contact.name or 'Гость'}",
|
||||
title_key="notifications.operator_needed",
|
||||
text_params={"contact": contact.name or t("conversations.guest")},
|
||||
body="AI временно недоступен, диалог ждёт ответа",
|
||||
body_key="notifications.ai_unavailable_waiting",
|
||||
target_id=conversation.id,
|
||||
source_type="Conversation",
|
||||
source_id=conversation.id,
|
||||
@@ -395,6 +418,9 @@ def ingest_inbound(integration, inbound: InboundMessage) -> None:
|
||||
type=NotificationType.INTEGRATION_ERROR,
|
||||
title=f"Ошибка AI · {channel.name}",
|
||||
body="AI временно недоступен, диалог передан оператору",
|
||||
title_key="notifications.ai_error",
|
||||
body_key="notifications.ai_unavailable_handed_over",
|
||||
text_params={"channel": channel.name},
|
||||
target_id=conversation.id,
|
||||
source_type="Conversation",
|
||||
source_id=conversation.id,
|
||||
@@ -418,12 +444,19 @@ def ingest_inbound(integration, inbound: InboundMessage) -> None:
|
||||
conversation.save(update_fields=["control_mode", "last_activity_at", "expected_responder"])
|
||||
|
||||
if handoff:
|
||||
Message.objects.create(conversation=conversation, author_type=MessageAuthor.SYSTEM, text="AI передал диалог оператору")
|
||||
Message.objects.create(
|
||||
conversation=conversation,
|
||||
author_type=MessageAuthor.SYSTEM,
|
||||
system_event=SystemEvent.AI_HANDED_OVER,
|
||||
text="AI передал диалог оператору",
|
||||
)
|
||||
notify(
|
||||
context=context,
|
||||
type=NotificationType.DIALOG_WAITING,
|
||||
audience=NotificationAudience.OPERATORS,
|
||||
title=f"AI передал диалог · {contact.name or 'Гость'}",
|
||||
title_key="notifications.ai_handed_over",
|
||||
text_params={"contact": contact.name or t("conversations.guest")},
|
||||
body=ai_input[:120],
|
||||
target_id=conversation.id,
|
||||
source_type="Conversation",
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
# Generated by Django 5.2.17 on 2026-09-09 19:15
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('conversations', '0020_conversation_last_message_at_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='message',
|
||||
name='system_event',
|
||||
field=models.CharField(blank=True, choices=[('operator_took', 'Оператор перехватил диалог'), ('returned_to_ai', 'Диалог возвращён AI'), ('returned_to_queue', 'Диалог возвращён в очередь'), ('ai_unavailable', 'AI недоступен'), ('ai_handed_over', 'AI передал диалог оператору'), ('call_requested', 'Запрошен звонок'), ('call_accepted', 'Клиент принял приглашение'), ('call_declined', 'Клиент отклонил приглашение'), ('call_cancelled', 'Приглашение отменено'), ('call_missed', 'Звонок пропущен'), ('call_expired', 'Приглашение истекло'), ('call_started', 'Звонок начался'), ('call_ended', 'Звонок завершён'), ('call_failed', 'Звонок не состоялся')], default='', max_length=32),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='message',
|
||||
name='system_params',
|
||||
field=models.JSONField(blank=True, default=dict),
|
||||
),
|
||||
]
|
||||
@@ -4,6 +4,7 @@ from django.contrib.postgres.search import SearchVector
|
||||
from django.db import models
|
||||
from django.utils import timezone
|
||||
|
||||
from chatballs.i18n import t
|
||||
from chatballs.tenancy.models import TenantRelationModel
|
||||
|
||||
# Минимальный домен диалогов (ADR-HUB-0001/0002/0003/0006). Состояние диалога
|
||||
@@ -254,7 +255,34 @@ class MessageAuthor(models.TextChoices):
|
||||
CONTACT = "CONTACT", "Клиент"
|
||||
AI = "AI", "AI"
|
||||
OPERATOR = "OPERATOR", "Оператор"
|
||||
SYSTEM = "SYSTEM", "Система"
|
||||
SYSTEM = "SYSTEM", t("admin.actor_system")
|
||||
|
||||
|
||||
class SystemEvent(models.TextChoices):
|
||||
"""Код системного события диалога.
|
||||
|
||||
Текст события раньше писался в ``text`` по-русски и оставался таким
|
||||
навсегда: история — записи, а не подписи, и перевести её задним числом
|
||||
нельзя. Поэтому в базу идёт код, а фразу собирает интерфейс на языке того,
|
||||
кто её читает. ``text`` продолжает заполняться: он остаётся и запасным
|
||||
вариантом для строк, записанных до этого поля, и тем, что видно в базе
|
||||
глазами.
|
||||
"""
|
||||
|
||||
OPERATOR_TOOK = "operator_took", "Оператор перехватил диалог"
|
||||
RETURNED_TO_AI = "returned_to_ai", "Диалог возвращён AI"
|
||||
RETURNED_TO_QUEUE = "returned_to_queue", "Диалог возвращён в очередь"
|
||||
AI_UNAVAILABLE = "ai_unavailable", "AI недоступен"
|
||||
AI_HANDED_OVER = "ai_handed_over", "AI передал диалог оператору"
|
||||
CALL_REQUESTED = "call_requested", "Запрошен звонок"
|
||||
CALL_ACCEPTED = "call_accepted", "Клиент принял приглашение"
|
||||
CALL_DECLINED = "call_declined", "Клиент отклонил приглашение"
|
||||
CALL_CANCELLED = "call_cancelled", "Приглашение отменено"
|
||||
CALL_MISSED = "call_missed", "Звонок пропущен"
|
||||
CALL_EXPIRED = "call_expired", "Приглашение истекло"
|
||||
CALL_STARTED = "call_started", "Звонок начался"
|
||||
CALL_ENDED = "call_ended", "Звонок завершён"
|
||||
CALL_FAILED = "call_failed", "Звонок не состоялся"
|
||||
|
||||
|
||||
class MessageKind(models.TextChoices):
|
||||
@@ -300,6 +328,11 @@ class Message(TenantRelationModel):
|
||||
# телефона), полученный контакт. Пустая строка = текст.
|
||||
kind = models.CharField(max_length=32, choices=MessageKind.choices, default=MessageKind.TEXT, blank=True)
|
||||
text = models.TextField(blank=True)
|
||||
# Системное событие: код и его параметры (имя оператора, длительность).
|
||||
# Пустой код — обычное сообщение либо системная запись, сделанная до
|
||||
# появления поля; такие показываются по сохранённому тексту.
|
||||
system_event = models.CharField(max_length=32, choices=SystemEvent.choices, blank=True, default="")
|
||||
system_params = models.JSONField(default=dict, blank=True)
|
||||
# Санитизированный HTML входящего email. Остальные транспорты и исходящие
|
||||
# ответы используют plain text.
|
||||
content_html = models.TextField(blank=True)
|
||||
@@ -323,6 +356,13 @@ class Message(TenantRelationModel):
|
||||
ordering = ["created_at"]
|
||||
indexes = [
|
||||
# Полнотекстовый поиск по сообщениям (поиск в списке диалогов).
|
||||
#
|
||||
# Конфигурация «russian» покрывает обе переписки, и второй индекс
|
||||
# под английский был бы тратой места: в ней asciiword отдан
|
||||
# english_stem, а word — russian_stem, поэтому английские слова
|
||||
# стеммятся английским стеммером, а русские русским. Обратное
|
||||
# неверно: «english» оставляет кириллицу без основы. Проверено
|
||||
# тестом conversations/test_search_language.py.
|
||||
GinIndex(
|
||||
SearchVector("text", config="russian"),
|
||||
name="conv_message_text_fts",
|
||||
|
||||
@@ -22,6 +22,8 @@ def poll_all_messengers(context) -> int:
|
||||
channel__is_active=True,
|
||||
).exclude(secret="")
|
||||
if integration.config.get("purpose") != "notifications"
|
||||
# Демо-подключения из демо-набора: токены ненастоящие, опрашивать нечего.
|
||||
and not integration.config.get("demoSeed")
|
||||
]
|
||||
total = 0
|
||||
for integration in integrations:
|
||||
|
||||
@@ -8,6 +8,7 @@ from chatballs.conversations.contacts_merge import merge_contacts, revert_merge
|
||||
from chatballs.conversations.models import Contact
|
||||
from chatballs.conversations.stats import sales_overview_stats
|
||||
from chatballs.conversations.view_base import ConversationViewBase
|
||||
from chatballs.i18n import t
|
||||
from chatballs.identity.audit import record_audit_event
|
||||
from chatballs.identity.models import EmployeeRole
|
||||
|
||||
@@ -41,23 +42,23 @@ class ClientDetailView(ConversationViewBase):
|
||||
try:
|
||||
return Response({"client": client_detail(self._org(request).id, contact_id)})
|
||||
except Contact.DoesNotExist:
|
||||
return Response({"detail": "Клиент не найден"}, status=404)
|
||||
return Response({"detail": t("sales.client_not_found")}, status=404)
|
||||
|
||||
def patch(self, request: Request, contact_id: int) -> Response:
|
||||
organization = self._org(request)
|
||||
try:
|
||||
contact = Contact.objects.get(id=contact_id, organization=organization)
|
||||
except Contact.DoesNotExist:
|
||||
return Response({"detail": "Клиент не найден"}, status=404)
|
||||
return Response({"detail": t("sales.client_not_found")}, status=404)
|
||||
changed: list[str] = []
|
||||
for field, limit in self.LIMITS.items():
|
||||
if field not in request.data:
|
||||
continue
|
||||
value = str(request.data.get(field) or "").strip()
|
||||
if len(value) > limit:
|
||||
return Response({"detail": f"Поле {field}: не длиннее {limit} символов"}, status=400)
|
||||
return Response({"detail": t("conversations.field_too_long", field=field, limit=limit)}, status=400)
|
||||
if field == "name" and not value:
|
||||
return Response({"detail": "Имя контакта не может быть пустым"}, status=400)
|
||||
return Response({"detail": t("conversations.contact_name_empty")}, status=400)
|
||||
setattr(contact, field, value)
|
||||
changed.append(field)
|
||||
if changed:
|
||||
@@ -86,7 +87,7 @@ class ClientMergeView(ConversationViewBase):
|
||||
def _owner_only(self, request: Request) -> Response | None:
|
||||
membership = request.tenant_context.membership
|
||||
if membership is None or membership.role != EmployeeRole.OWNER:
|
||||
return Response({"detail": "Объединять контакты может только владелец"}, status=403)
|
||||
return Response({"detail": t("sales.merge_owner_only")}, status=403)
|
||||
return None
|
||||
|
||||
def post(self, request: Request, contact_id: int) -> Response:
|
||||
|
||||
@@ -7,11 +7,23 @@ from chatballs.conversations.models import (
|
||||
MessageAuthor,
|
||||
MessageKind,
|
||||
)
|
||||
from chatballs.i18n import t
|
||||
from chatballs.identity.avatars import user_avatar_url_in
|
||||
from chatballs.integrations.features import features_payload
|
||||
from chatballs.integrations.models import IntegrationProvider
|
||||
|
||||
|
||||
def _system_text(message: Message) -> str:
|
||||
params = message.system_params or {}
|
||||
# Вид звонка приходит кодом (AUDIO/VIDEO): слово для него — тоже в каталоге.
|
||||
if params.get("kind"):
|
||||
params = {**params, "kind": t(f"calls.kind_{str(params['kind']).lower()}")}
|
||||
rendered = t(f"conversations.system.{message.system_event}", **params)
|
||||
# Ключа нет в каталоге — t вернул сам ключ; тогда честнее показать то, что
|
||||
# записано, чем служебный код.
|
||||
return message.text if rendered.startswith("conversations.system.") else rendered
|
||||
|
||||
|
||||
def message_payload(message: Message) -> dict[str, object]:
|
||||
payload = {
|
||||
"id": message.id,
|
||||
@@ -21,7 +33,14 @@ def message_payload(message: Message) -> dict[str, object]:
|
||||
"authorName": (message.author_user.full_name or message.author_user.email) if message.author_user_id and message.author_user else "",
|
||||
"authorAvatarUrl": user_avatar_url_in(message.author_user, message.organization_id) if message.author_user_id and message.author_user else None,
|
||||
"kind": message.kind,
|
||||
"text": message.text,
|
||||
# Системное событие собирается по коду на языке запроса: историю
|
||||
# диалога читают оба — и русскоязычный оператор, и англоязычный, — а
|
||||
# записана она один раз. Код без перевода и записи, сделанные до его
|
||||
# появления, приходят сохранённым текстом.
|
||||
"text": _system_text(message) if message.system_event else message.text,
|
||||
# Код нужен интерфейсу ещё и для тона строки: передача — предупреждение,
|
||||
# взятие — акцент. Раньше тон угадывался регуляркой по русским словам.
|
||||
"systemEvent": message.system_event,
|
||||
"contentHtml": message.content_html,
|
||||
"createdAt": message.created_at.isoformat(),
|
||||
}
|
||||
|
||||
@@ -11,18 +11,13 @@ from chatballs.conversations.models import (
|
||||
Message,
|
||||
MessageAuthor,
|
||||
MessageKind,
|
||||
SystemEvent,
|
||||
)
|
||||
from chatballs.i18n import customer_language, t
|
||||
from chatballs.identity.models import EmployeeRole
|
||||
from chatballs.integrations.models import IntegrationProvider
|
||||
from chatballs.tenancy.context import TenantContext
|
||||
|
||||
CONTACT_REQUEST_TEXT = "Поделитесь, пожалуйста, контактом — нажмите кнопку ниже."
|
||||
|
||||
CONTACT_REQUEST_TEXT_WEB = "Поделитесь, пожалуйста, номером телефона."
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
class ClaimError(Exception):
|
||||
|
||||
@@ -36,7 +31,7 @@ def _require_open(conversation: Conversation) -> None:
|
||||
|
||||
if conversation.lifecycle != LifecycleState.OPEN:
|
||||
|
||||
raise ClaimError("Диалог закрыт")
|
||||
raise ClaimError(t("conversations.closed"))
|
||||
|
||||
|
||||
|
||||
@@ -76,7 +71,7 @@ def claim_locked_conversation(*, context: TenantContext, conversation: Conversat
|
||||
|
||||
if operator is None or conversation.organization_id != context.organization_id:
|
||||
|
||||
raise ClaimError("Диалог недоступен")
|
||||
raise ClaimError(t("conversations.unavailable"))
|
||||
|
||||
_require_open(conversation)
|
||||
|
||||
@@ -98,7 +93,7 @@ def claim_locked_conversation(*, context: TenantContext, conversation: Conversat
|
||||
|
||||
):
|
||||
|
||||
raise ClaimError("Диалог уже ведёт другой оператор")
|
||||
raise ClaimError(t("calls.conversation_taken"))
|
||||
|
||||
conversation.control_mode = ControlMode.HUMAN
|
||||
|
||||
@@ -114,6 +109,10 @@ def claim_locked_conversation(*, context: TenantContext, conversation: Conversat
|
||||
|
||||
author_type=MessageAuthor.SYSTEM,
|
||||
|
||||
system_event=SystemEvent.OPERATOR_TOOK,
|
||||
|
||||
system_params={"operator": _operator_label(operator)},
|
||||
|
||||
text=f"Оператор {_operator_label(operator)} перехватил диалог",
|
||||
|
||||
)
|
||||
@@ -140,7 +139,7 @@ def release_to_ai(*, context: TenantContext, conversation_id: int) -> Conversati
|
||||
|
||||
if agent is None or not agent.is_active:
|
||||
|
||||
raise ClaimError("У канала нет активного AI-агента")
|
||||
raise ClaimError(t("channels.no_active_agent"))
|
||||
|
||||
conversation.control_mode = ControlMode.AI
|
||||
|
||||
@@ -150,7 +149,12 @@ def release_to_ai(*, context: TenantContext, conversation_id: int) -> Conversati
|
||||
|
||||
conversation.save(update_fields=["control_mode", "assigned_operator", "expected_responder"])
|
||||
|
||||
Message.objects.create(conversation=conversation, author_type=MessageAuthor.SYSTEM, text="Диалог возвращён AI")
|
||||
Message.objects.create(
|
||||
conversation=conversation,
|
||||
author_type=MessageAuthor.SYSTEM,
|
||||
system_event=SystemEvent.RETURNED_TO_AI,
|
||||
text="Диалог возвращён AI",
|
||||
)
|
||||
|
||||
return conversation
|
||||
|
||||
@@ -180,7 +184,12 @@ def return_to_queue(*, context: TenantContext, conversation_id: int) -> Conversa
|
||||
|
||||
conversation.save(update_fields=["control_mode", "assigned_operator", "expected_responder"])
|
||||
|
||||
Message.objects.create(conversation=conversation, author_type=MessageAuthor.SYSTEM, text="Диалог возвращён в очередь")
|
||||
Message.objects.create(
|
||||
conversation=conversation,
|
||||
author_type=MessageAuthor.SYSTEM,
|
||||
system_event=SystemEvent.RETURNED_TO_QUEUE,
|
||||
text="Диалог возвращён в очередь",
|
||||
)
|
||||
|
||||
return conversation
|
||||
|
||||
@@ -258,7 +267,9 @@ def request_contact(*, context: TenantContext, conversation: Conversation) -> Me
|
||||
|
||||
is_web = conversation.connection_id and conversation.connection.provider == IntegrationProvider.WEB
|
||||
|
||||
text = CONTACT_REQUEST_TEXT_WEB if is_web else CONTACT_REQUEST_TEXT
|
||||
key = "conversations.contact_request_web" if is_web else "conversations.contact_request"
|
||||
|
||||
text = t(key, language=customer_language(conversation.organization))
|
||||
|
||||
message = Message.objects.create(
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ from chatballs.conversations.models import (
|
||||
LifecycleState,
|
||||
Message,
|
||||
)
|
||||
from chatballs.i18n import t
|
||||
|
||||
_ACTIVE_WINDOW = timedelta(minutes=15)
|
||||
_WEEKDAYS = ["Пн", "Вт", "Ср", "Чт", "Пт", "Сб", "Вс"]
|
||||
@@ -126,7 +127,7 @@ def sales_overview_stats(context, period: str) -> dict:
|
||||
):
|
||||
minutes = int((now - conversation.last_activity_at).total_seconds() // 60)
|
||||
meta = conversation.channel.name
|
||||
title = (conversation.contact.name if conversation.contact_id else "") or "Гость"
|
||||
title = (conversation.contact.name if conversation.contact_id else "") or t("conversations.guest")
|
||||
problems.append(
|
||||
{
|
||||
"conversationId": conversation.id,
|
||||
|
||||
@@ -15,6 +15,7 @@ from chatballs.conversations.models import Contact, Conversation, MessageAuthor
|
||||
from chatballs.conversations.selectors import conversation_messages
|
||||
from chatballs.conversations.serializers import conversation_payload, message_payload
|
||||
from chatballs.conversations.transports import email as email_transport
|
||||
from chatballs.conversations.transports.errors import PollFailed
|
||||
from chatballs.identity.bootstrap import bootstrap_owner
|
||||
from chatballs.identity.models import Organization
|
||||
from chatballs.integrations.models import Integration, IntegrationKind, IntegrationProvider
|
||||
@@ -359,11 +360,11 @@ class EmailPollTests(TestCase):
|
||||
|
||||
with mock.patch.object(email_transport, "_imap_connect", side_effect=OSError("refused")):
|
||||
|
||||
messages, marker = email_transport.poll_updates(integration)
|
||||
with self.assertRaises(PollFailed):
|
||||
email_transport.poll_updates(integration)
|
||||
|
||||
self.assertEqual(messages, [])
|
||||
|
||||
self.assertEqual(marker, "7:99")
|
||||
# Курсор не сдвинулся: следующий удачный опрос продолжит с того же места.
|
||||
self.assertEqual(integration.poll_marker, "7:99")
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
"""Сбой опроса подключения: пауза с удвоением и тишина в журнале.
|
||||
|
||||
Раньше подключение с ненастоящим токеном писало предупреждение каждые три
|
||||
секунды. Теперь после сбоя оно пропускается с растущей паузой, журнал видит
|
||||
первый сбой, выход на максимальную паузу и восстановление, а курсор при
|
||||
сбое не двигается. Демо-подключения не опрашиваются вовсе.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest import mock
|
||||
|
||||
from django.test import TestCase
|
||||
|
||||
from chatballs.channels.models import Channel
|
||||
from chatballs.conversations import poller
|
||||
from chatballs.conversations.transports import backoff, poll
|
||||
from chatballs.conversations.transports.errors import PollFailed
|
||||
from chatballs.identity.models import Organization
|
||||
from chatballs.integrations.models import Integration, IntegrationKind, IntegrationProvider
|
||||
from chatballs.tenancy.context import TenantContext
|
||||
from chatballs.tenancy.database import tenant_atomic
|
||||
|
||||
|
||||
class PollBackoffTests(TestCase):
|
||||
def setUp(self) -> None:
|
||||
backoff.reset()
|
||||
self.addCleanup(backoff.reset)
|
||||
self.organization = Organization.objects.create(name="Poll", slug="poll-org")
|
||||
with tenant_atomic(self.organization.id):
|
||||
self.channel = Channel.objects.create(organization=self.organization, name="Main", code="main")
|
||||
self.integration = Integration.objects.create(
|
||||
organization=self.organization,
|
||||
kind=IntegrationKind.MESSENGER,
|
||||
provider=IntegrationProvider.TELEGRAM,
|
||||
name="Bot",
|
||||
secret="0000:not-a-token",
|
||||
channel=self.channel,
|
||||
poll_marker="41",
|
||||
)
|
||||
|
||||
def _fail(self, *_args, **_kwargs):
|
||||
raise PollFailed("HTTP Error 401: Unauthorized")
|
||||
|
||||
def test_failure_is_logged_once_and_then_skipped(self) -> None:
|
||||
with mock.patch.dict(poller.transports._POLL, {IntegrationProvider.TELEGRAM: self._fail}):
|
||||
with self.assertLogs("chatballs.conversations.transports.backoff", level="WARNING") as logs:
|
||||
self.assertEqual(poll(self.integration), ([], "41"))
|
||||
self.assertEqual(len(logs.output), 1)
|
||||
self.assertIn("401", logs.output[0])
|
||||
# Пока пауза не вышла, транспорт не вызывается и журнал молчит.
|
||||
with mock.patch.object(backoff, "_now", return_value=backoff._now()):
|
||||
with self.assertNoLogs("chatballs.conversations.transports.backoff", level="WARNING"):
|
||||
self.assertEqual(poll(self.integration), ([], "41"))
|
||||
self.assertTrue(backoff.should_skip(self.integration.id))
|
||||
|
||||
def test_delay_doubles_up_to_the_cap_and_recovery_is_logged(self) -> None:
|
||||
clock = [1000.0]
|
||||
with mock.patch.object(backoff, "_now", side_effect=lambda: clock[0]):
|
||||
with mock.patch.dict(poller.transports._POLL, {IntegrationProvider.TELEGRAM: self._fail}):
|
||||
delays = []
|
||||
for _ in range(10):
|
||||
poll(self.integration)
|
||||
state = backoff._failures[self.integration.id]
|
||||
delays.append(state.delay)
|
||||
clock[0] = state.next_attempt_at # ждём ровно до следующей попытки
|
||||
self.assertEqual(delays[:3], [6.0, 12.0, 24.0])
|
||||
self.assertEqual(delays[-1], backoff.MAX_DELAY_SECONDS)
|
||||
self.assertTrue(all(delay <= backoff.MAX_DELAY_SECONDS for delay in delays))
|
||||
|
||||
with mock.patch.dict(poller.transports._POLL, {IntegrationProvider.TELEGRAM: lambda _i: ([], "42")}):
|
||||
with self.assertLogs("chatballs.conversations.transports.backoff", level="INFO") as logs:
|
||||
self.assertEqual(poll(self.integration), ([], "42"))
|
||||
self.assertIn("recovered", logs.output[0])
|
||||
self.assertNotIn(self.integration.id, backoff._failures)
|
||||
|
||||
def test_demo_seed_connections_are_not_polled(self) -> None:
|
||||
with tenant_atomic(self.organization.id):
|
||||
Integration.objects.filter(pk=self.integration.pk).update(config={"demoSeed": True})
|
||||
context = TenantContext.for_resource(self.organization)
|
||||
with mock.patch("chatballs.conversations.poller.transports.poll") as polled:
|
||||
poller.poll_all_messengers(context)
|
||||
polled.assert_not_called()
|
||||
@@ -0,0 +1,59 @@
|
||||
"""Поиск по переписке на обоих языках идёт одной конфигурацией — «russian».
|
||||
|
||||
Выглядит как недосмотр: англоязычная установка ищет по своим письмам русской
|
||||
конфигурацией. На деле «russian» в PostgreSQL — не «русский стеммер на всё», а
|
||||
карта по типу токена: латиница уходит в english_stem, кириллица — в
|
||||
russian_stem. То есть английские слова уже стеммятся английским стеммером.
|
||||
|
||||
Обратное неверно, и это здесь же проверяется: «english» отдаёт кириллицу
|
||||
english_stem и оставляет её без основы. Поэтому второй индекс под английский не
|
||||
нужен, а замена конфигурации на «english» была бы регрессом для русской
|
||||
переписки. Тест стоит затем, чтобы это не «починили» ещё раз.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from django.db import connection
|
||||
from django.test import TestCase
|
||||
|
||||
from chatballs.conversations.models import Message
|
||||
|
||||
|
||||
class SearchConfigTests(TestCase):
|
||||
def _matches(self, config: str, text: str, query: str) -> bool:
|
||||
with connection.cursor() as cursor:
|
||||
cursor.execute(
|
||||
"SELECT to_tsvector(%s, %s) @@ websearch_to_tsquery(%s, %s)",
|
||||
[config, text, config, query],
|
||||
)
|
||||
return cursor.fetchone()[0]
|
||||
|
||||
def test_russian_config_stems_english_words(self) -> None:
|
||||
self.assertTrue(self._matches("russian", "the deliveries arrived", "delivery"))
|
||||
self.assertTrue(self._matches("russian", "we are shipping orders", "shipped"))
|
||||
|
||||
def test_russian_config_stems_russian_words(self) -> None:
|
||||
self.assertTrue(self._matches("russian", "заказы доставлены", "заказ"))
|
||||
|
||||
def test_english_config_leaves_russian_words_unstemmed(self) -> None:
|
||||
# Ровно причина, по которой конфигурация не переключается по языку
|
||||
# организации: на английской конфигурации «заказы» не находит «заказ».
|
||||
self.assertFalse(self._matches("english", "заказы доставлены", "заказ"))
|
||||
|
||||
def test_messages_are_indexed_with_the_same_configuration(self) -> None:
|
||||
# Конфигурация входит в выражение индекса: разойдись она с запросом —
|
||||
# поиск ушёл бы в полный скан. Читаем определение из самой базы, а не
|
||||
# из питоновского объекта: в базе лежит то, что реально применится.
|
||||
with connection.cursor() as cursor:
|
||||
cursor.execute(
|
||||
"SELECT indexdef FROM pg_indexes WHERE indexname = %s",
|
||||
["conv_message_text_fts"],
|
||||
)
|
||||
row = cursor.fetchone()
|
||||
self.assertIsNotNone(row, "индекс полнотекстового поиска не создан")
|
||||
self.assertIn("'russian'", row[0])
|
||||
self.assertEqual(
|
||||
[i.name for i in Message._meta.indexes if "fts" in i.name],
|
||||
["conv_message_text_fts"],
|
||||
"второй индекс под язык не нужен: «russian» уже покрывает латиницу",
|
||||
)
|
||||
@@ -1,6 +1,9 @@
|
||||
from chatballs.conversations.transports import backoff
|
||||
from chatballs.conversations.transports import email as _email
|
||||
from chatballs.conversations.transports import max as _max
|
||||
from chatballs.conversations.transports import telegram as _telegram
|
||||
from chatballs.conversations.transports.errors import PollFailed
|
||||
from chatballs.i18n import t
|
||||
from chatballs.integrations.models import IntegrationProvider
|
||||
|
||||
_POLL = {
|
||||
@@ -50,7 +53,22 @@ SUPPORTED_PROVIDERS = tuple(_POLL.keys())
|
||||
|
||||
|
||||
def poll(integration):
|
||||
return _POLL[integration.provider](integration)
|
||||
"""Опрос подключения с паузой после сбоя (transports.backoff).
|
||||
|
||||
Сбой транспорта не роняет цикл и не пишется в журнал на каждой попытке:
|
||||
подключение пропускается с растущей паузой, а журнал видит только смену
|
||||
состояния. Курсор при сбое не двигается.
|
||||
"""
|
||||
|
||||
if backoff.should_skip(integration.id):
|
||||
return [], integration.poll_marker
|
||||
try:
|
||||
result = _POLL[integration.provider](integration)
|
||||
except PollFailed as error:
|
||||
backoff.record_failure(integration, error)
|
||||
return [], integration.poll_marker
|
||||
backoff.record_success(integration)
|
||||
return result
|
||||
|
||||
|
||||
def send_reply(integration, *, chat_id: str, user_id: str, text: str) -> bool:
|
||||
@@ -114,7 +132,7 @@ def download_file(integration, inbound_file) -> tuple[bytes, str]:
|
||||
return content, inbound_file.content_type or guessed
|
||||
if integration.provider == IntegrationProvider.MAX and inbound_file.url:
|
||||
return _max.download_file(integration, inbound_file.url, inbound_file.content_type)
|
||||
raise ValueError("File download is not supported for this provider")
|
||||
raise ValueError(t("conversations.file_download_unsupported"))
|
||||
|
||||
|
||||
def supports_file_send(integration) -> bool:
|
||||
@@ -143,7 +161,7 @@ def download_voice(integration, inbound) -> tuple[bytes, str]:
|
||||
return _telegram.download_voice(integration, inbound.voice_file_id)
|
||||
if integration.provider == IntegrationProvider.MAX and inbound.voice_url:
|
||||
return _max.download_voice(integration, inbound.voice_url)
|
||||
raise ValueError("Voice download is not supported for this provider")
|
||||
raise ValueError(t("conversations.voice_download_unsupported"))
|
||||
|
||||
|
||||
def supports_voice_send(integration) -> bool:
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
"""Пауза между попытками опроса подключения после сбоя.
|
||||
|
||||
Воркер опрашивает мессенджеры каждые три секунды. Подключение с отозванным
|
||||
токеном или недоступным сервером отвечало ошибкой на каждый цикл и писало
|
||||
её в журнал двадцать раз в минуту — журнал переставал быть читаемым, а
|
||||
чужой сервер получал бессмысленный поток запросов. Теперь после сбоя
|
||||
подключение пропускается с растущей паузой, а в журнал попадают только
|
||||
изменения состояния: первый сбой, выход на максимальную паузу и
|
||||
восстановление.
|
||||
|
||||
Состояние живёт в памяти процесса: воркер один, а после перезапуска первая
|
||||
попытка всё равно нужна.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Первая пауза — два цикла опроса, дальше удвоение до четверти часа.
|
||||
FIRST_DELAY_SECONDS = 6.0
|
||||
MAX_DELAY_SECONDS = 900.0
|
||||
|
||||
|
||||
@dataclass
|
||||
class _Failure:
|
||||
failures: int
|
||||
next_attempt_at: float
|
||||
delay: float
|
||||
|
||||
|
||||
_failures: dict[int, _Failure] = {}
|
||||
|
||||
|
||||
def _now() -> float:
|
||||
return time.monotonic()
|
||||
|
||||
|
||||
def should_skip(integration_id: int) -> bool:
|
||||
state = _failures.get(integration_id)
|
||||
return state is not None and _now() < state.next_attempt_at
|
||||
|
||||
|
||||
def record_failure(integration, error: object) -> None:
|
||||
previous = _failures.get(integration.id)
|
||||
failures = (previous.failures if previous else 0) + 1
|
||||
delay = min(FIRST_DELAY_SECONDS * 2 ** (failures - 1), MAX_DELAY_SECONDS)
|
||||
_failures[integration.id] = _Failure(failures=failures, next_attempt_at=_now() + delay, delay=delay)
|
||||
if failures == 1:
|
||||
logger.warning(
|
||||
"%s poll failed for integration %s: %s (next attempt in %.0fs)",
|
||||
integration.provider, integration.id, error, delay,
|
||||
)
|
||||
elif delay >= MAX_DELAY_SECONDS and (previous is None or previous.delay < MAX_DELAY_SECONDS):
|
||||
logger.warning(
|
||||
"%s poll keeps failing for integration %s: %s (retrying every %.0f min)",
|
||||
integration.provider, integration.id, error, MAX_DELAY_SECONDS / 60,
|
||||
)
|
||||
|
||||
|
||||
def record_success(integration) -> None:
|
||||
state = _failures.pop(integration.id, None)
|
||||
if state is not None:
|
||||
logger.info(
|
||||
"%s poll recovered for integration %s after %s failure(s)",
|
||||
integration.provider, integration.id, state.failures,
|
||||
)
|
||||
|
||||
|
||||
def reset() -> None:
|
||||
"""Для тестов: забыть все сбои."""
|
||||
|
||||
_failures.clear()
|
||||
@@ -10,6 +10,7 @@ from dataclasses import dataclass, field
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
from chatballs.i18n import t
|
||||
from chatballs.integrations.outbound import ensure_downloadable
|
||||
from chatballs.integrations.proxy import build_opener
|
||||
|
||||
@@ -104,7 +105,7 @@ def download_bytes(
|
||||
with opener.open(request, timeout=settings.CHATBALLS_AI_REQUEST_TIMEOUT) as response:
|
||||
data = response.read(max_bytes + 1)
|
||||
if len(data) > max_bytes:
|
||||
raise ValueError("Файл больше допустимого размера")
|
||||
raise ValueError(t("conversations.file_too_large"))
|
||||
return data
|
||||
|
||||
|
||||
|
||||
@@ -26,6 +26,8 @@ from chatballs.conversations.transports.base import (
|
||||
guess_content_type,
|
||||
safe_filename,
|
||||
)
|
||||
from chatballs.conversations.transports.errors import PollFailed
|
||||
from chatballs.i18n import customer_language, t
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -134,8 +136,7 @@ def poll_updates(integration) -> tuple[list[InboundMessage], str]:
|
||||
try:
|
||||
client = _imap_connect(integration)
|
||||
except (imaplib.IMAP4.error, OSError, TimeoutError) as error:
|
||||
logger.warning("Email IMAP poll failed for integration %s: %s", integration.id, error)
|
||||
return [], integration.poll_marker
|
||||
raise PollFailed(str(error)) from error
|
||||
try:
|
||||
client.select("INBOX", readonly=True)
|
||||
validity = _status_value(client, "UIDVALIDITY")
|
||||
@@ -161,8 +162,7 @@ def poll_updates(integration) -> tuple[list[InboundMessage], str]:
|
||||
new_marker = f"{validity}:{uids[-1]}" if uids else integration.poll_marker
|
||||
return messages, new_marker
|
||||
except (imaplib.IMAP4.error, OSError, TimeoutError) as error:
|
||||
logger.warning("Email IMAP poll failed for integration %s: %s", integration.id, error)
|
||||
return [], integration.poll_marker
|
||||
raise PollFailed(str(error)) from error
|
||||
finally:
|
||||
try:
|
||||
client.logout()
|
||||
@@ -194,7 +194,8 @@ def send_text(integration, *, chat_id: str, user_id: str, text: str, attachments
|
||||
outgoing["From"] = _address(integration)
|
||||
outgoing["To"] = target
|
||||
subject = str(meta.get("subject", "")).strip()
|
||||
outgoing["Subject"] = f"Re: {subject}" if subject and not subject.lower().startswith("re:") else (subject or "Re: Ваше обращение")
|
||||
default_subject = t("conversations.email_default_subject", language=customer_language(integration.organization))
|
||||
outgoing["Subject"] = f"Re: {subject}" if subject and not subject.lower().startswith("re:") else f"Re: {subject or default_subject}"
|
||||
last_message_id = str(meta.get("last_message_id", "")).strip()
|
||||
if last_message_id:
|
||||
outgoing["In-Reply-To"] = last_message_id
|
||||
@@ -223,8 +224,10 @@ def send_text(integration, *, chat_id: str, user_id: str, text: str, attachments
|
||||
def send_voice(integration, *, chat_id: str, user_id: str, content: bytes, content_type: str, duration: int) -> bool:
|
||||
"""Голосовое оператора уходит письмом с аудио-вложением."""
|
||||
suffix = "ogg" if "ogg" in content_type else (content_type.rsplit("/", 1)[-1] or "webm").split(";")[0]
|
||||
return send_text(integration, chat_id=chat_id, user_id=user_id, text="Голосовое сообщение", attachments=[(content, f"voice.{suffix}", content_type)])
|
||||
text = t("conversations.voice_message", language=customer_language(integration.organization))
|
||||
return send_text(integration, chat_id=chat_id, user_id=user_id, text=text, attachments=[(content, f"voice.{suffix}", content_type)])
|
||||
|
||||
|
||||
def send_file(integration, *, chat_id: str, user_id: str, content: bytes, filename: str, content_type: str, caption: str = "") -> bool:
|
||||
return send_text(integration, chat_id=chat_id, user_id=user_id, text=caption or f"Файл: {filename}", attachments=[(content, filename, content_type)])
|
||||
text = caption or t("conversations.file_caption", name=filename, language=customer_language(integration.organization))
|
||||
return send_text(integration, chat_id=chat_id, user_id=user_id, text=text, attachments=[(content, filename, content_type)])
|
||||
@@ -0,0 +1,6 @@
|
||||
class PollFailed(Exception):
|
||||
"""Опрос подключения не удался: сеть, авторизация, ответ не разобран.
|
||||
|
||||
Транспорт не пишет об этом в журнал сам — решение, когда повторить и что
|
||||
записать, принимает слой пауз между попытками (``transports.backoff``).
|
||||
"""
|
||||
@@ -27,6 +27,8 @@ from chatballs.conversations.transports.base import (
|
||||
request_json_multipart,
|
||||
safe_filename,
|
||||
)
|
||||
from chatballs.conversations.transports.errors import PollFailed
|
||||
from chatballs.i18n import customer_language, t
|
||||
from chatballs.integrations.checks import DEFAULT_MAX_BASE_URL
|
||||
from chatballs.integrations.outbound import host_of
|
||||
|
||||
@@ -241,8 +243,7 @@ def poll_updates(integration) -> tuple[list[InboundMessage], str]:
|
||||
try:
|
||||
data = request_json(url, headers={"Authorization": token, "Content-Type": "application/json"}, proxy_url=_proxy(integration))
|
||||
except (urllib.error.URLError, TimeoutError, OSError, http.client.HTTPException, json.JSONDecodeError) as error:
|
||||
logger.warning("MAX poll failed for integration %s: %s", integration.id, error)
|
||||
return [], integration.poll_marker
|
||||
raise PollFailed(str(error)) from error
|
||||
updates = data.get("updates") or []
|
||||
messages = [m for m in (_normalize(u) for u in updates) if m is not None]
|
||||
new_marker = data.get("marker")
|
||||
@@ -271,11 +272,17 @@ def send_text(integration, *, chat_id: str, user_id: str, text: str) -> bool:
|
||||
return _send(integration, chat_id=chat_id, user_id=user_id, body={"text": text})
|
||||
|
||||
|
||||
def _caption(integration, key: str) -> str:
|
||||
"""Подпись кнопки читает клиент — язык организации, а не язык запроса."""
|
||||
|
||||
return t(key, language=customer_language(integration.organization))
|
||||
|
||||
|
||||
def send_contact_request(integration, *, chat_id: str, user_id: str, text: str) -> bool:
|
||||
# Inline-клавиатура с кнопкой request_contact (Bot API MAX/TamTam).
|
||||
keyboard = {
|
||||
"type": "inline_keyboard",
|
||||
"payload": {"buttons": [[{"type": "request_contact", "text": "Поделиться контактом"}]]},
|
||||
"payload": {"buttons": [[{"type": "request_contact", "text": _caption(integration, "conversations.button_share_contact")}]]},
|
||||
}
|
||||
return _send(integration, chat_id=chat_id, user_id=user_id, body={"text": text, "attachments": [keyboard]})
|
||||
|
||||
@@ -284,7 +291,7 @@ def send_call_invite(integration, *, chat_id: str, user_id: str, text: str, url:
|
||||
# Приглашение на онлайн-звонок: inline-кнопка со ссылкой /calls/<token>.
|
||||
keyboard = {
|
||||
"type": "inline_keyboard",
|
||||
"payload": {"buttons": [[{"type": "link", "text": "Перейти к звонку", "url": url}]]},
|
||||
"payload": {"buttons": [[{"type": "link", "text": _caption(integration, "conversations.button_join_call"), "url": url}]]},
|
||||
}
|
||||
return _send(integration, chat_id=chat_id, user_id=user_id, body={"text": text, "attachments": [keyboard]})
|
||||
|
||||
|
||||
@@ -23,6 +23,8 @@ from chatballs.conversations.transports.base import (
|
||||
request_json_multipart,
|
||||
safe_filename,
|
||||
)
|
||||
from chatballs.conversations.transports.errors import PollFailed
|
||||
from chatballs.i18n import customer_language, t
|
||||
from chatballs.integrations.checks import DEFAULT_TELEGRAM_BASE_URL
|
||||
from chatballs.integrations.outbound import host_of
|
||||
|
||||
@@ -123,8 +125,7 @@ def poll_updates(integration) -> tuple[list[InboundMessage], str]:
|
||||
try:
|
||||
data = request_json(url, proxy_url=_proxy(integration))
|
||||
except (urllib.error.URLError, TimeoutError, OSError, http.client.HTTPException, json.JSONDecodeError) as error:
|
||||
logger.warning("Telegram poll failed for integration %s: %s", integration.id, error)
|
||||
return [], integration.poll_marker
|
||||
raise PollFailed(str(error)) from error
|
||||
if not data.get("ok"):
|
||||
return [], integration.poll_marker
|
||||
updates = data.get("result") or []
|
||||
@@ -152,10 +153,16 @@ def send_text(integration, *, chat_id: str, user_id: str, text: str) -> bool:
|
||||
return _send(integration, chat_id=chat_id, user_id=user_id, body={"text": text})
|
||||
|
||||
|
||||
def _caption(integration, key: str) -> str:
|
||||
"""Подпись кнопки читает клиент — язык организации, а не язык запроса."""
|
||||
|
||||
return t(key, language=customer_language(integration.organization))
|
||||
|
||||
|
||||
def send_contact_request(integration, *, chat_id: str, user_id: str, text: str) -> bool:
|
||||
# Reply-клавиатура с request_contact: телефон бот получает только так.
|
||||
keyboard = {
|
||||
"keyboard": [[{"text": "Поделиться контактом", "request_contact": True}]],
|
||||
"keyboard": [[{"text": _caption(integration, "conversations.button_share_contact"), "request_contact": True}]],
|
||||
"one_time_keyboard": True,
|
||||
"resize_keyboard": True,
|
||||
}
|
||||
@@ -169,7 +176,7 @@ def send_contact_ack(integration, *, chat_id: str, user_id: str, text: str) -> b
|
||||
|
||||
def send_call_invite(integration, *, chat_id: str, user_id: str, text: str, url: str) -> bool:
|
||||
# Приглашение на онлайн-звонок: inline-кнопка со ссылкой /calls/<token>.
|
||||
keyboard = {"inline_keyboard": [[{"text": "Перейти к звонку", "url": url}]]}
|
||||
keyboard = {"inline_keyboard": [[{"text": _caption(integration, "conversations.button_join_call"), "url": url}]]}
|
||||
return _send(integration, chat_id=chat_id, user_id=user_id, body={"text": text, "reply_markup": keyboard})
|
||||
|
||||
|
||||
@@ -182,7 +189,7 @@ def download_file(integration, file_id: str) -> tuple[bytes, str]:
|
||||
)
|
||||
file_path = ((data.get("result") or {}).get("file_path") or "")
|
||||
if not data.get("ok") or not file_path:
|
||||
raise ValueError("Telegram getFile failed")
|
||||
raise ValueError(t("conversations.telegram_getfile_failed"))
|
||||
content = download_bytes(
|
||||
f"{_base(integration)}/file/bot{token}/{file_path}",
|
||||
proxy_url=_proxy(integration),
|
||||
@@ -226,7 +233,7 @@ def download_voice(integration, file_id: str) -> tuple[bytes, str]:
|
||||
)
|
||||
file_path = ((data.get("result") or {}).get("file_path") or "")
|
||||
if not data.get("ok") or not file_path:
|
||||
raise ValueError("Telegram getFile failed")
|
||||
raise ValueError(t("conversations.telegram_getfile_failed"))
|
||||
content = download_bytes(
|
||||
f"{_base(integration)}/file/bot{token}/{file_path}",
|
||||
proxy_url=_proxy(integration),
|
||||
|
||||
@@ -39,6 +39,7 @@ from chatballs.conversations.services import (
|
||||
return_to_queue,
|
||||
)
|
||||
from chatballs.conversations.view_base import ConversationViewBase
|
||||
from chatballs.i18n import t
|
||||
from chatballs.identity.group_models import EmployeeGroup
|
||||
from chatballs.identity.models import OrganizationMembership
|
||||
from chatballs.identity.policy import ResourceScope, authorize, can_administer_access
|
||||
@@ -58,7 +59,7 @@ class ConversationListView(ConversationViewBase):
|
||||
# «Удалённые» (архив) скрыты; просмотр архива — только администратор.
|
||||
if params.get("archived") == "1":
|
||||
if not can_administer_access(request.tenant_context.membership):
|
||||
return Response({"detail": "Архив доступен администраторам"}, status=403)
|
||||
return Response({"detail": t("conversations.archive_admin_only")}, status=403)
|
||||
items = items.filter(archived_at__isnull=False)
|
||||
else:
|
||||
items = items.filter(archived_at__isnull=True)
|
||||
@@ -92,7 +93,11 @@ class ConversationListView(ConversationViewBase):
|
||||
from django.db.models import Exists, OuterRef
|
||||
|
||||
# Имена — по подстроке; тексты сообщений — полнотекстовым поиском
|
||||
# (russian-конфиг, GIN-индекс conv_message_text_fts).
|
||||
# (russian-конфиг, GIN-индекс conv_message_text_fts). Конфигурация
|
||||
# одна на оба языка и от языка организации не зависит: в «russian»
|
||||
# латиница отдана english_stem, а кириллица — russian_stem, так что
|
||||
# английская переписка стеммится английским стеммером. Менять её на
|
||||
# «english» нельзя — та оставит без основы русские слова.
|
||||
search = SearchQuery(query, config="russian", search_type="websearch")
|
||||
message_match = (
|
||||
Message.objects.filter(conversation=OuterRef("pk"))
|
||||
@@ -145,7 +150,7 @@ class ConversationDetailView(ConversationViewBase):
|
||||
try:
|
||||
conversation = self._conversation(request, conversation_id)
|
||||
except Conversation.DoesNotExist:
|
||||
return Response({"detail": "Диалог не найден"}, status=404)
|
||||
return Response({"detail": t("conversations.not_found")}, status=404)
|
||||
# Открытие диалога = прочтение: двигаем персональную отметку до последнего
|
||||
# сообщения (detail поллится каждые 3 с — пишем только при продвижении).
|
||||
last_id = conversation.messages.order_by("-id").values_list("id", flat=True).first() or 0
|
||||
@@ -174,7 +179,7 @@ class ConversationClaimView(ConversationViewBase):
|
||||
try:
|
||||
self._conversation(request, conversation_id, self.required_capability)
|
||||
except Conversation.DoesNotExist:
|
||||
return Response({"detail": "Диалог не найден"}, status=404)
|
||||
return Response({"detail": t("conversations.not_found")}, status=404)
|
||||
try:
|
||||
conversation = claim_conversation(
|
||||
context=request.tenant_context, conversation_id=conversation_id
|
||||
@@ -200,7 +205,7 @@ class ConversationReleaseView(ConversationViewBase):
|
||||
try:
|
||||
self._conversation(request, conversation_id, self.required_capability)
|
||||
except Conversation.DoesNotExist:
|
||||
return Response({"detail": "Диалог не найден"}, status=404)
|
||||
return Response({"detail": t("conversations.not_found")}, status=404)
|
||||
try:
|
||||
conversation = release_to_ai(
|
||||
context=request.tenant_context, conversation_id=conversation_id
|
||||
@@ -226,7 +231,7 @@ class ConversationReturnQueueView(ConversationViewBase):
|
||||
try:
|
||||
self._conversation(request, conversation_id, self.required_capability)
|
||||
except Conversation.DoesNotExist:
|
||||
return Response({"detail": "Диалог не найден"}, status=404)
|
||||
return Response({"detail": t("conversations.not_found")}, status=404)
|
||||
try:
|
||||
conversation = return_to_queue(
|
||||
context=request.tenant_context, conversation_id=conversation_id
|
||||
@@ -264,7 +269,7 @@ def claim_for_reply(view: ConversationViewBase, request: Request, conversation:
|
||||
ResourceScope(conversation.organization_id),
|
||||
)
|
||||
if conversation.assigned_operator_id != request.user.id and not manager_override:
|
||||
return Response({"detail": "Диалог ведёт другой оператор"}, status=409)
|
||||
return Response({"detail": t("conversations.handled_by_other")}, status=409)
|
||||
return conversation
|
||||
|
||||
|
||||
@@ -284,7 +289,7 @@ class ConversationMessageView(ConversationViewBase):
|
||||
try:
|
||||
conversation = self._conversation(request, conversation_id)
|
||||
except Conversation.DoesNotExist:
|
||||
return Response({"detail": "Диалог не найден"}, status=404)
|
||||
return Response({"detail": t("conversations.not_found")}, status=404)
|
||||
params = request.query_params
|
||||
limit = window_size(params, default=MESSAGE_WINDOW_SIZE)
|
||||
messages = conversation_messages(conversation)
|
||||
@@ -313,12 +318,12 @@ class ConversationMessageView(ConversationViewBase):
|
||||
try:
|
||||
conversation = self._conversation(request, conversation_id, self.required_capability)
|
||||
except Conversation.DoesNotExist:
|
||||
return Response({"detail": "Диалог не найден"}, status=404)
|
||||
return Response({"detail": t("conversations.not_found")}, status=404)
|
||||
text = str(request.data.get("text", "")).strip()
|
||||
if not text:
|
||||
return Response({"detail": "Пустое сообщение"}, status=400)
|
||||
return Response({"detail": t("ai.empty_message")}, status=400)
|
||||
if conversation.lifecycle != LifecycleState.OPEN:
|
||||
return Response({"detail": "Диалог закрыт"}, status=409)
|
||||
return Response({"detail": t("conversations.closed")}, status=409)
|
||||
claimed = claim_for_reply(self, request, conversation)
|
||||
if isinstance(claimed, Response):
|
||||
return claimed
|
||||
@@ -339,13 +344,13 @@ class ConversationRequestContactView(ConversationViewBase):
|
||||
try:
|
||||
conversation = self._conversation(request, conversation_id, self.required_capability)
|
||||
except Conversation.DoesNotExist:
|
||||
return Response({"detail": "Диалог не найден"}, status=404)
|
||||
return Response({"detail": t("conversations.not_found")}, status=404)
|
||||
if not conversation.contact_id or not conversation.connection_id:
|
||||
return Response({"detail": "У диалога нет канала для запроса контакта"}, status=409)
|
||||
return Response({"detail": t("conversations.no_channel_for_contact")}, status=409)
|
||||
if conversation.lifecycle != LifecycleState.OPEN:
|
||||
return Response({"detail": "Диалог закрыт"}, status=409)
|
||||
return Response({"detail": t("conversations.closed")}, status=409)
|
||||
if conversation.contact.phone:
|
||||
return Response({"detail": "Контакт уже получен"}, status=409)
|
||||
return Response({"detail": t("conversations.contact_already_received")}, status=409)
|
||||
try:
|
||||
message = request_contact(
|
||||
context=request.tenant_context,
|
||||
@@ -364,7 +369,7 @@ class ConversationCloseView(ConversationViewBase):
|
||||
try:
|
||||
self._conversation(request, conversation_id, self.required_capability)
|
||||
except Conversation.DoesNotExist:
|
||||
return Response({"detail": "Диалог не найден"}, status=404)
|
||||
return Response({"detail": t("conversations.not_found")}, status=404)
|
||||
try:
|
||||
conversation = close_conversation(
|
||||
context=request.tenant_context, conversation_id=conversation_id
|
||||
@@ -390,7 +395,7 @@ class ConversationSpamView(ConversationViewBase):
|
||||
try:
|
||||
self._conversation(request, conversation_id, self.required_capability)
|
||||
except Conversation.DoesNotExist:
|
||||
return Response({"detail": "Диалог не найден"}, status=404)
|
||||
return Response({"detail": t("conversations.not_found")}, status=404)
|
||||
try:
|
||||
conversation = mark_conversation_as_spam(
|
||||
context=request.tenant_context, conversation_id=conversation_id
|
||||
@@ -418,7 +423,7 @@ class ConversationGroupView(ConversationViewBase):
|
||||
try:
|
||||
conversation = self._conversation(request, conversation_id, self.required_capability)
|
||||
except Conversation.DoesNotExist:
|
||||
return Response({"detail": "Диалог не найден"}, status=404)
|
||||
return Response({"detail": t("conversations.not_found")}, status=404)
|
||||
group_id = request.data.get("groupId")
|
||||
group = None
|
||||
if group_id is not None:
|
||||
@@ -426,7 +431,7 @@ class ConversationGroupView(ConversationViewBase):
|
||||
organization_id=conversation.organization_id, id=group_id
|
||||
).first()
|
||||
if group is None:
|
||||
return Response({"detail": "Группа не найдена"}, status=400)
|
||||
return Response({"detail": t("admin.group_not_found")}, status=400)
|
||||
conversation.group = group
|
||||
conversation.save(update_fields=["group"])
|
||||
self._audit(request, "group_changed", conversation)
|
||||
@@ -448,7 +453,7 @@ class ConversationAssigneeView(ConversationViewBase):
|
||||
try:
|
||||
conversation = self._conversation(request, conversation_id, self.required_capability)
|
||||
except Conversation.DoesNotExist:
|
||||
return Response({"detail": "Диалог не найден"}, status=404)
|
||||
return Response({"detail": t("conversations.not_found")}, status=404)
|
||||
user_id = request.data.get("userId")
|
||||
assignee = None
|
||||
if user_id is not None:
|
||||
@@ -462,7 +467,7 @@ class ConversationAssigneeView(ConversationViewBase):
|
||||
.first()
|
||||
)
|
||||
if membership is None:
|
||||
return Response({"detail": "Сотрудник не найден"}, status=400)
|
||||
return Response({"detail": t("admin.employee_not_found")}, status=400)
|
||||
assignee = membership.user
|
||||
conversation.assigned_operator = assignee
|
||||
conversation.save(update_fields=["assigned_operator"])
|
||||
|
||||
@@ -29,6 +29,7 @@ from chatballs.conversations.models import (
|
||||
from chatballs.conversations.selectors import conversation_for_context
|
||||
from chatballs.conversations.serializers import message_payload
|
||||
from chatballs.conversations.view_base import ConversationViewBase
|
||||
from chatballs.i18n import t
|
||||
from chatballs.integrations.features import voice_messages_allowed
|
||||
from chatballs.tenancy.database import tenant_atomic
|
||||
|
||||
@@ -53,9 +54,9 @@ class MessageAudioView(ConversationViewBase):
|
||||
try:
|
||||
message = _visible_message(request, message_id)
|
||||
except (Message.DoesNotExist, Conversation.DoesNotExist):
|
||||
return Response({"detail": "Сообщение не найдено"}, status=404)
|
||||
return Response({"detail": t("conversations.message_not_found")}, status=404)
|
||||
if not message.audio:
|
||||
return Response({"detail": "Аудио недоступно"}, status=404)
|
||||
return Response({"detail": t("conversations.audio_unavailable")}, status=404)
|
||||
return FileResponse(
|
||||
message.audio.open("rb"),
|
||||
content_type=message.audio_content_type or "application/octet-stream",
|
||||
@@ -86,9 +87,9 @@ class MessageTranscribeView(ConversationViewBase):
|
||||
try:
|
||||
message = _visible_message(request, message_id)
|
||||
except (Message.DoesNotExist, Conversation.DoesNotExist):
|
||||
return Response({"detail": "Сообщение не найдено"}, status=404)
|
||||
return Response({"detail": t("conversations.message_not_found")}, status=404)
|
||||
if message.kind != MessageKind.VOICE or not message.audio:
|
||||
return Response({"detail": "Это не голосовое сообщение"}, status=400)
|
||||
return Response({"detail": t("conversations.not_a_voice_message")}, status=400)
|
||||
if message.transcript_status == TranscriptStatus.READY:
|
||||
return Response({"message": message_payload(message)})
|
||||
try:
|
||||
@@ -97,7 +98,7 @@ class MessageTranscribeView(ConversationViewBase):
|
||||
mark_transcription_failed(message)
|
||||
return Response({"detail": str(error)}, status=502)
|
||||
if job is None:
|
||||
return Response({"detail": "Аудио недоступно"}, status=404)
|
||||
return Response({"detail": t("conversations.audio_unavailable")}, status=404)
|
||||
|
||||
try:
|
||||
transcript = run_transcription(job)
|
||||
@@ -110,7 +111,7 @@ class MessageTranscribeView(ConversationViewBase):
|
||||
store_transcription(message, transcript)
|
||||
payload = message_payload(message)
|
||||
if not transcript:
|
||||
return Response({"detail": "Провайдер вернул пустую расшифровку"}, status=502)
|
||||
return Response({"detail": t("ai.empty_transcript")}, status=502)
|
||||
return Response({"message": payload})
|
||||
|
||||
|
||||
@@ -129,24 +130,24 @@ class ConversationVoiceView(ConversationViewBase):
|
||||
request, conversation_id, self.required_capability
|
||||
)
|
||||
except Conversation.DoesNotExist:
|
||||
return Response({"detail": "Диалог не найден"}, status=404)
|
||||
return Response({"detail": t("conversations.not_found")}, status=404)
|
||||
if conversation.lifecycle != LifecycleState.OPEN:
|
||||
return Response({"detail": "Диалог закрыт"}, status=409)
|
||||
return Response({"detail": t("conversations.closed")}, status=409)
|
||||
connection = conversation.connection
|
||||
if connection is None or not transports.supports_voice_send(connection):
|
||||
return Response(
|
||||
{"detail": "Голосовые сообщения недоступны в этом канале"}, status=400
|
||||
{"detail": t("conversations.voice_unavailable_channel")}, status=400
|
||||
)
|
||||
if not voice_messages_allowed(connection):
|
||||
return Response({"detail": "Голосовые отключены для этой точки входа"}, status=400)
|
||||
return Response({"detail": t("conversations.voice_off_entry_point")}, status=400)
|
||||
upload = request.FILES.get("audio")
|
||||
if upload is None:
|
||||
return Response({"detail": "Прикрепите аудио"}, status=400)
|
||||
return Response({"detail": t("conversations.attach_audio")}, status=400)
|
||||
if upload.size > MAX_VOICE_BYTES:
|
||||
return Response({"detail": "Аудио больше 10 МБ"}, status=400)
|
||||
return Response({"detail": t("conversations.audio_too_large")}, status=400)
|
||||
content_type = (upload.content_type or "audio/ogg").split(";")[0]
|
||||
if content_type not in ALLOWED_AUDIO_TYPES:
|
||||
return Response({"detail": "Неподдерживаемый формат аудио"}, status=400)
|
||||
return Response({"detail": t("conversations.audio_format_unsupported")}, status=400)
|
||||
try:
|
||||
duration = max(0, int(request.data.get("duration", 0)))
|
||||
except (TypeError, ValueError):
|
||||
@@ -169,7 +170,7 @@ class ConversationVoiceView(ConversationViewBase):
|
||||
)
|
||||
if not sent:
|
||||
return Response(
|
||||
{"detail": "Не удалось отправить голосовое в канал"}, status=502
|
||||
{"detail": t("conversations.voice_send_failed")}, status=502
|
||||
)
|
||||
message = Message.objects.create(
|
||||
conversation=conversation,
|
||||
|
||||
@@ -11,10 +11,11 @@ from chatballs.conversations.poller import poll_all_messengers
|
||||
from chatballs.events.handlers import dispatch
|
||||
from chatballs.events.models import OutboxStatus
|
||||
from chatballs.events.services import claim_next_outbox_event, mark_retry
|
||||
from chatballs.identity.models import Organization
|
||||
from chatballs.notifications.binding import poll_notifier_bots
|
||||
from chatballs.tenancy.context import TenantActorKind, TenantContext
|
||||
from chatballs.tenancy.database import tenant_atomic
|
||||
from chatballs.tenancy.lookup import iter_organizations
|
||||
from chatballs.updates.services import check_for_updates
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -28,7 +29,7 @@ class Command(BaseCommand):
|
||||
|
||||
@staticmethod
|
||||
def _tenant_contexts():
|
||||
for organization in Organization.objects.order_by("id").iterator():
|
||||
for organization in iter_organizations():
|
||||
yield TenantContext.for_resource(
|
||||
organization, actor_kind=TenantActorKind.SYSTEM
|
||||
)
|
||||
@@ -92,6 +93,12 @@ class Command(BaseCommand):
|
||||
logger.exception("Call sweep cycle failed")
|
||||
if now - last_maintenance >= MAINTENANCE_INTERVAL:
|
||||
last_maintenance = now
|
||||
# Канал релизов спрашивается не чаще раза в несколько часов:
|
||||
# интервал держит сама проверка по времени последнего ответа.
|
||||
try:
|
||||
check_for_updates()
|
||||
except Exception: # pragma: no cover
|
||||
logger.exception("Update check cycle failed")
|
||||
try:
|
||||
for context in self._tenant_contexts():
|
||||
with tenant_atomic(context):
|
||||
|
||||
@@ -7,8 +7,9 @@ from django.utils import timezone
|
||||
|
||||
from chatballs.events.context import get_correlation_id
|
||||
from chatballs.events.models import EventOwnership, OutboxEvent, OutboxStatus
|
||||
from chatballs.identity.models import Organization, OrganizationMembership
|
||||
from chatballs.identity.models import OrganizationMembership
|
||||
from chatballs.tenancy.context import TenantActorKind, TenantContext
|
||||
from chatballs.tenancy.lookup import load_organization
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -43,7 +44,9 @@ def tenant_context_for_event(event: OutboxEvent) -> TenantContext | None:
|
||||
return None
|
||||
if event.organization_id is None:
|
||||
raise ValueError("Tenant event has no organization")
|
||||
organization = Organization.objects.get(pk=event.organization_id)
|
||||
organization = load_organization(event.organization_id)
|
||||
if organization is None:
|
||||
raise ValueError("Tenant event organization does not exist")
|
||||
try:
|
||||
actor_kind = TenantActorKind(event.actor_kind)
|
||||
except ValueError as error:
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
"""Язык интерфейса: выбор языка запроса и каталог сообщений."""
|
||||
|
||||
from chatballs.i18n.audience import customer_language
|
||||
from chatballs.i18n.catalog import current_language, t, tn
|
||||
from chatballs.i18n.languages import (
|
||||
DEFAULT_LANGUAGE,
|
||||
ENGLISH,
|
||||
INHERIT,
|
||||
LANGUAGE_CODES,
|
||||
LANGUAGES,
|
||||
RUSSIAN,
|
||||
first_chosen,
|
||||
normalize_language,
|
||||
parse_accept_language,
|
||||
resolve_language,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"DEFAULT_LANGUAGE",
|
||||
"ENGLISH",
|
||||
"INHERIT",
|
||||
"LANGUAGES",
|
||||
"LANGUAGE_CODES",
|
||||
"RUSSIAN",
|
||||
"current_language",
|
||||
"customer_language",
|
||||
"first_chosen",
|
||||
"normalize_language",
|
||||
"parse_accept_language",
|
||||
"resolve_language",
|
||||
"t",
|
||||
"tn",
|
||||
]
|
||||
@@ -0,0 +1,24 @@
|
||||
"""Язык текста, который уходит наружу — клиенту, а не сотруднику.
|
||||
|
||||
Язык запроса здесь не годится. Приглашение на звонок, просьба поделиться
|
||||
контактом, тема исходящего письма — это речь организации, обращённая к её
|
||||
клиенту, и она не должна менять язык от того, кто из операторов нажал кнопку.
|
||||
Часть такого текста вообще рождается в воркере, где запроса нет.
|
||||
|
||||
Поэтому берём язык организации, а если она его не задавала — язык установки.
|
||||
Язык самого клиента нам неизвестен: в Telegram, MAX и почте его сообщить
|
||||
нечему, а в веб-виджете он выбирается на стороне страницы.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from chatballs.i18n.languages import INHERIT, first_chosen, normalize_language
|
||||
|
||||
|
||||
def customer_language(organization: object) -> str:
|
||||
"""Язык обращения организации к клиенту. Пусто — язык по умолчанию."""
|
||||
|
||||
from chatballs.identity.instance_settings import default_language
|
||||
|
||||
organization_language = normalize_language(getattr(organization, "language", INHERIT))
|
||||
return first_chosen(organization_language, default_language())
|
||||
@@ -0,0 +1,97 @@
|
||||
"""Каталог сообщений и перевод строки под язык запроса.
|
||||
|
||||
Переводы лежат обычными питоновскими словарями, а не в .po: продукт ставят
|
||||
одной командой из готового образа, и лишний бинарный шаг сборки (msgfmt) в этой
|
||||
цепочке нечем оправдать. Django и DRF свои строки при этом продолжают
|
||||
переводить штатно — язык запроса активируется через ``translation.activate``,
|
||||
и их скомпилированные каталоги подхватываются как обычно.
|
||||
|
||||
Русский каталог — источник истины: ключ появляется сначала в нём. Английский
|
||||
обязан повторять его набор ключей один в один, это проверяется тестом, а не
|
||||
глазами. Пропущенный перевод не роняет запрос: строка выходит по-русски, и это
|
||||
видно, тогда как исключение в середине ответа не видно никому.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Final
|
||||
|
||||
from django.utils.translation import get_language
|
||||
|
||||
from chatballs.i18n.languages import DEFAULT_LANGUAGE, normalize_language
|
||||
from chatballs.i18n.messages import CATALOG
|
||||
|
||||
# Форма множественного числа по правилам CLDR. Русскому нужны четыре («1 диалог»,
|
||||
# «2 диалога», «5 диалогов», «1,5 диалога»), английскому — две, и код, который
|
||||
# просто клеит «s», на русском не работает ни в одной строке.
|
||||
PluralForms = dict[str, str]
|
||||
Message = str | PluralForms
|
||||
|
||||
_OTHER: Final = "other"
|
||||
|
||||
|
||||
def _plural_category(language: str, count: int | float) -> str:
|
||||
if language == "ru":
|
||||
if count != int(count):
|
||||
return _OTHER
|
||||
i = abs(int(count))
|
||||
if i % 10 == 1 and i % 100 != 11:
|
||||
return "one"
|
||||
if i % 10 in (2, 3, 4) and i % 100 not in (12, 13, 14):
|
||||
return "few"
|
||||
return "many"
|
||||
# Английский и всё, что добавят следом с двумя формами.
|
||||
if count == 1 and count == int(count):
|
||||
return "one"
|
||||
return _OTHER
|
||||
|
||||
|
||||
def _lookup(key: str, language: str) -> Message | None:
|
||||
table = CATALOG.get(language)
|
||||
if table is not None and key in table:
|
||||
return table[key]
|
||||
if language != DEFAULT_LANGUAGE:
|
||||
fallback = CATALOG.get(DEFAULT_LANGUAGE)
|
||||
if fallback is not None and key in fallback:
|
||||
return fallback[key]
|
||||
return None
|
||||
|
||||
|
||||
def current_language() -> str:
|
||||
"""Язык, активированный для этого запроса (или язык по умолчанию)."""
|
||||
|
||||
return normalize_language(get_language()) or DEFAULT_LANGUAGE
|
||||
|
||||
|
||||
def t(key: str, /, language: str | None = None, **params: object) -> str:
|
||||
"""Строка каталога на языке запроса, с подстановкой ``{имя}``.
|
||||
|
||||
Неизвестный ключ возвращается как есть. Это заметно в интерфейсе, но не
|
||||
ломает ответ: опечатка в ключе не должна превращать сохранение настроек в
|
||||
пятисотую.
|
||||
"""
|
||||
|
||||
code = normalize_language(language) or current_language()
|
||||
message = _lookup(key, code)
|
||||
if message is None:
|
||||
return key
|
||||
if isinstance(message, dict):
|
||||
message = message.get(_OTHER, key)
|
||||
return message.format(**params) if params else message
|
||||
|
||||
|
||||
def tn(key: str, count: int | float, /, language: str | None = None, **params: object) -> str:
|
||||
"""Строка с числом: форма выбирается по правилам языка.
|
||||
|
||||
``count`` подставляется в ``{count}`` без отдельного указания, потому что
|
||||
строка с числом без самого числа не нужна ни разу.
|
||||
"""
|
||||
|
||||
code = normalize_language(language) or current_language()
|
||||
message = _lookup(key, code)
|
||||
if message is None:
|
||||
return key
|
||||
if isinstance(message, dict):
|
||||
category = _plural_category(code, count)
|
||||
message = message.get(category) or message.get(_OTHER) or key
|
||||
return message.format(count=count, **params)
|
||||
@@ -0,0 +1,113 @@
|
||||
"""Языки интерфейса и правило, по которому запрос выбирает свой.
|
||||
|
||||
Язык не выводится из окружения и не задаётся переменной: его выбирает человек.
|
||||
Владелец задаёт язык организации — на нём рабочее место открывается всем, кто
|
||||
в неё вошёл; отдельный сотрудник может переопределить его в профиле, и тогда
|
||||
организация ему не указ. До входа организации ещё нет, и язык берётся из
|
||||
настроек установки: на нём показываются логин, сброс пароля и мастер первого
|
||||
запуска.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Final
|
||||
|
||||
RUSSIAN: Final = "ru"
|
||||
ENGLISH: Final = "en"
|
||||
|
||||
# Порядок важен: первый язык — тот, на котором написан исходный каталог и на
|
||||
# который приходится фолбэк, когда перевода нет.
|
||||
LANGUAGES: Final[tuple[tuple[str, str], ...]] = (
|
||||
(RUSSIAN, "Русский"),
|
||||
(ENGLISH, "English"),
|
||||
)
|
||||
|
||||
LANGUAGE_CODES: Final[tuple[str, ...]] = tuple(code for code, _ in LANGUAGES)
|
||||
DEFAULT_LANGUAGE: Final = RUSSIAN
|
||||
|
||||
# Пустая строка в профиле сотрудника означает «как в организации», а в
|
||||
# организации — «как в установке». Это не язык, а отсутствие выбора, поэтому
|
||||
# отдельного значения в LANGUAGES у неё нет.
|
||||
INHERIT: Final = ""
|
||||
|
||||
|
||||
def normalize_language(value: object) -> str:
|
||||
"""Привести код к поддерживаемому языку или вернуть INHERIT.
|
||||
|
||||
Принимает и «ru-RU», и «RU», и «ru_ru»: браузер, операционная система и
|
||||
заголовок Accept-Language пишут регион по-разному, а выбор один и тот же.
|
||||
Неизвестный язык — не ошибка: это просьба, которую нечем удовлетворить, и
|
||||
она сводится к «как выше по цепочке».
|
||||
"""
|
||||
|
||||
if not isinstance(value, str):
|
||||
return INHERIT
|
||||
code = value.strip().replace("_", "-").lower()
|
||||
if not code:
|
||||
return INHERIT
|
||||
base = code.partition("-")[0]
|
||||
return base if base in LANGUAGE_CODES else INHERIT
|
||||
|
||||
|
||||
def parse_accept_language(header: str | None) -> str:
|
||||
"""Первый поддерживаемый язык из Accept-Language, иначе INHERIT.
|
||||
|
||||
Заголовок разбирается по убыванию q-веса: «en;q=0.9, ru» означает русский,
|
||||
хотя английский стоит первым.
|
||||
"""
|
||||
|
||||
if not header:
|
||||
return INHERIT
|
||||
weighted: list[tuple[float, int, str]] = []
|
||||
for position, part in enumerate(header.split(",")):
|
||||
token, _, params = part.strip().partition(";")
|
||||
code = normalize_language(token)
|
||||
if not code:
|
||||
continue
|
||||
quality = 1.0
|
||||
for param in params.split(";"):
|
||||
key, _, raw = param.strip().partition("=")
|
||||
if key.strip() == "q":
|
||||
try:
|
||||
quality = float(raw)
|
||||
except ValueError:
|
||||
quality = 0.0
|
||||
weighted.append((-quality, position, code))
|
||||
if not weighted:
|
||||
return INHERIT
|
||||
weighted.sort()
|
||||
return weighted[0][2]
|
||||
|
||||
|
||||
def first_chosen(*candidates: str) -> str:
|
||||
"""Первый уровень цепочки, который язык действительно выбрал.
|
||||
|
||||
Возвращает INHERIT, если не выбрал ни один: вызывающий по этому признаку
|
||||
решает, спрашивать ли следующий уровень. Нужно там, где следующий уровень
|
||||
стоит запроса к базе, — незачем его читать, если выбор уже сделан.
|
||||
"""
|
||||
|
||||
for candidate in candidates:
|
||||
code = normalize_language(candidate)
|
||||
if code:
|
||||
return code
|
||||
return INHERIT
|
||||
|
||||
|
||||
def resolve_language(
|
||||
*,
|
||||
user_language: str = INHERIT,
|
||||
organization_language: str = INHERIT,
|
||||
instance_language: str = INHERIT,
|
||||
accept_language: str | None = None,
|
||||
) -> str:
|
||||
"""Язык одного запроса: от личного выбора к общему умолчанию.
|
||||
|
||||
Порядок фиксированный — профиль, организация, установка, браузер. Браузер
|
||||
стоит последним и работает только до входа: после входа язык человека уже
|
||||
известен, и подстраиваться под ноутбук, с которого он сегодня зашёл, не
|
||||
нужно.
|
||||
"""
|
||||
|
||||
chosen = first_chosen(user_language, organization_language, instance_language)
|
||||
return chosen or parse_accept_language(accept_language) or DEFAULT_LANGUAGE
|
||||
@@ -0,0 +1,14 @@
|
||||
"""Сборка каталога: русский — источник истины, английский обязан его повторить.
|
||||
|
||||
Ключ пишется как «модуль.смысл» и не повторяет саму фразу: строку правят чаще,
|
||||
чем переименовывают, и ключ вида «identity.email_taken» переживает
|
||||
переформулировку, а «identity.email_already_used» — нет.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from chatballs.i18n.messages import en, ru
|
||||
|
||||
CATALOG: dict[str, dict[str, object]] = {"ru": ru.MESSAGES, "en": en.MESSAGES}
|
||||
|
||||
__all__ = ["CATALOG"]
|
||||
@@ -0,0 +1,545 @@
|
||||
"""English catalogue. Mirrors ru.py key for key (checked by tests)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
MESSAGES: dict[str, object] = {
|
||||
"admin.choose_logo_file": "Choose a logo file",
|
||||
"admin.currency_rub_only": "Only the Russian rouble (RUB) is supported",
|
||||
"admin.employee_not_found": "Operator not found",
|
||||
"admin.group_name_taken": "A group with this name already exists",
|
||||
"admin.group_not_found": "Group not found",
|
||||
"admin.invalid_timezone": "Enter a valid time zone",
|
||||
"admin.logo_not_uploaded": "No logo has been uploaded",
|
||||
"admin.multi_org_self_reset": "This operator belongs to several organizations — they reset the password themselves through access recovery",
|
||||
"admin.name_too_long": "The name must not exceed 255 characters",
|
||||
"admin.organization_name_required": "Enter the organization name",
|
||||
"admin.photo_not_found": "Photo not found",
|
||||
"ai.agent_not_found": "Agent not found",
|
||||
"ai.demo_no_transcription": "Voice transcription is not available on the demo provider: connect OpenRouter or a compatible provider under Settings.",
|
||||
"ai.empty_message": "The message is empty",
|
||||
"ai.empty_transcript": "The provider returned an empty transcript",
|
||||
"ai.provider_not_configured": "No AI provider is configured: a call without a channel cannot pick an integration",
|
||||
"api.window_needs_sort_key": "a window needs at least one sort key",
|
||||
"calls.invite_invalid": "The invitation is not valid or has expired",
|
||||
"calls.kind_audio": "an audio call",
|
||||
"calls.kind_invalid": "Unsupported call type",
|
||||
"calls.kind_required": "Choose the call type",
|
||||
"calls.kind_video": "a video call",
|
||||
"calls.not_found": "Call not found",
|
||||
"calls.token_invalid": "The call access token is not valid or has expired",
|
||||
"channels.binding_needs_manage": "Binding a connection requires integrations.manage",
|
||||
"channels.cannot_delete_linked": "The channel cannot be deleted: it has related records",
|
||||
"channels.no_rights_to_change": "No rights to change the channel",
|
||||
"conversations.archive_admin_only": "The archive is available to administrators",
|
||||
"conversations.attach_audio": "Attach audio",
|
||||
"conversations.attach_file": "Attach a file",
|
||||
"conversations.audio_format_unsupported": "Unsupported audio format",
|
||||
"conversations.audio_too_large": "The audio is over 10 MB",
|
||||
"conversations.audio_unavailable": "The audio is unavailable",
|
||||
"conversations.closed": "The conversation is closed",
|
||||
"conversations.contact_already_received": "The contact details have already been received",
|
||||
"conversations.contact_name_empty": "The contact name cannot be empty",
|
||||
"conversations.file_send_failed": "Could not send the file to the channel",
|
||||
"conversations.file_too_large": "The file is larger than allowed",
|
||||
"conversations.file_unavailable": "The file is unavailable",
|
||||
"conversations.files_unavailable_channel": "Files are not available on this channel",
|
||||
"conversations.guest": "Guest",
|
||||
"conversations.handled_by_other": "Another operator is handling this conversation",
|
||||
"conversations.invalid_colour": "Invalid colour",
|
||||
"conversations.label_name_length": "Label name: 1-60 characters",
|
||||
"conversations.label_not_found": "Label not found",
|
||||
"conversations.message_not_found": "Message not found",
|
||||
"conversations.no_channel_for_contact": "The conversation has no channel to request contact details through",
|
||||
"conversations.no_contact": "The conversation has no contact",
|
||||
"conversations.not_a_voice_message": "This is not a voice message",
|
||||
"conversations.not_found": "Conversation not found",
|
||||
"conversations.note_too_long": "The note is longer than 4000 characters",
|
||||
"conversations.restore_admin_only": "Only an administrator can restore a conversation",
|
||||
"conversations.system.ai_handed_over": "AI handed the conversation to an operator",
|
||||
"conversations.system.ai_unavailable": "AI is unavailable — the conversation was handed to an operator",
|
||||
"conversations.system.call_accepted": "The customer accepted the call invitation",
|
||||
"conversations.system.call_cancelled": "The operator cancelled the call invitation",
|
||||
"conversations.system.call_declined": "The customer declined the call invitation",
|
||||
"conversations.system.call_ended": "Call ended · {duration}",
|
||||
"conversations.system.call_expired": "The call invitation expired",
|
||||
"conversations.system.call_failed": "The call did not go through",
|
||||
"conversations.system.call_missed": "Call missed: the customer did not answer",
|
||||
"conversations.system.call_requested": "{operator} requested {kind}",
|
||||
"conversations.system.call_started": "The call started: the connection is up",
|
||||
"conversations.system.operator_took": "{operator} took over the conversation",
|
||||
"conversations.system.returned_to_ai": "The conversation was handed back to AI",
|
||||
"conversations.system.returned_to_queue": "The conversation was returned to the queue",
|
||||
"conversations.template_name_length": "Template name: 1-120 characters",
|
||||
"conversations.template_name_taken": "A template with this name already exists",
|
||||
"conversations.template_not_found": "Template not found",
|
||||
"conversations.template_text_required": "The template text is required",
|
||||
"conversations.unknown_label": "Unknown label",
|
||||
"conversations.unknown_priority": "Unknown priority",
|
||||
"conversations.voice_off_entry_point": "Voice messages are switched off for this entry point",
|
||||
"conversations.voice_send_failed": "Could not send the voice message to the channel",
|
||||
"conversations.voice_unavailable_channel": "Voice messages are not available on this channel",
|
||||
"emails.initial_access_body": "Hello {name},\n\nA Chatballs account has been created for you. To set your first password, follow this link:\n{url}\n\nThe link is valid for 30 minutes. If you were not expecting this email, contact your organization's owner.",
|
||||
"emails.initial_access_subject": "Your Chatballs access",
|
||||
"emails.password_reset_body": "Hello {name},\n\nYou asked to reset your Chatballs password. To set a new one, follow this link:\n{url}\n\nThe link is valid for 30 minutes. If you did not ask for a reset, simply ignore this email.",
|
||||
"emails.password_reset_subject": "Chatballs access recovery",
|
||||
"emails.membership_invitation_subject": "Invitation to {organization}",
|
||||
"emails.membership_invitation_body": "Hello, {name}.\n\nYou are invited to join the organization \u201c{organization}\u201d in Chatballs. To accept the invitation, follow this link:\n{url}\n\nThe link is valid for 7 days. If you did not expect this e-mail, simply ignore it.",
|
||||
"identity.link_invalid_or_expired": "The link is not valid or has expired",
|
||||
"identity.password_needs_digit": "The password must contain a digit.",
|
||||
"identity.password_needs_letter": "The password must contain a letter.",
|
||||
"identity.password_needs_special": "The password must contain a special character.",
|
||||
"identity.setup_already_done": "The first run is already complete: the organization exists",
|
||||
"notifications.ai_error": "AI error · {channel}",
|
||||
"notifications.ai_handed_over": "AI handed over the conversation · {contact}",
|
||||
"notifications.ai_unavailable_handed_over": "AI is temporarily unavailable; the conversation was handed to an operator",
|
||||
"notifications.ai_unavailable_waiting": "AI is temporarily unavailable; the conversation is waiting for an answer",
|
||||
"notifications.new_dialog": "New conversation · {channel}",
|
||||
"notifications.new_dialog_body": "{contact} · {provider}: {preview}",
|
||||
"notifications.new_message": "New message · {contact}",
|
||||
"notifications.operator_needed": "An operator is needed · {contact}",
|
||||
"notifications.voice_without_transcript": "A voice message without a transcript",
|
||||
"portals.article_not_found": "Article not found",
|
||||
"portals.file_field_required": "A file is required in the file field (multipart/form-data)",
|
||||
"portals.file_not_found": "File not found",
|
||||
"portals.helpful_must_be_boolean": "helpful must be a boolean",
|
||||
"portals.invalid_article": "Invalid article",
|
||||
"portals.invalid_article_data": "Invalid article data",
|
||||
"portals.invalid_category": "Invalid category",
|
||||
"portals.invalid_domain": "Invalid domain name",
|
||||
"portals.invalid_pagination": "Invalid pagination",
|
||||
"portals.invalid_revision": "Invalid revision",
|
||||
"portals.invalid_section": "Invalid section",
|
||||
"portals.not_found": "Portal not found",
|
||||
"portals.section_not_found": "Section not found",
|
||||
"profile.accent_hex": "The accent is a HEX colour like #1677ff",
|
||||
"profile.choose_photo_file": "Choose a photo file",
|
||||
"profile.link_not_found": "Link not found",
|
||||
"profile.notification_bot_not_found": "Notification bot not found",
|
||||
"profile.photo_not_set": "No photo is set",
|
||||
"profile.totp_already_on": "TOTP is already on: turn it off with your current password first",
|
||||
"profile.unknown_theme": "Unknown theme",
|
||||
"sales.already_merged": "This contact is already merged with another — unmerge it first",
|
||||
"sales.already_unmerged": "This merge has already been undone",
|
||||
"sales.client_not_found": "Customer not found",
|
||||
"sales.contact_not_found": "Contact not found",
|
||||
"sales.merge_not_found": "Merge not found",
|
||||
"sales.merge_owner_only": "Only the owner can merge contacts",
|
||||
"sales.merge_reason_required": "Give a reason for the merge — it goes into the action log",
|
||||
"sales.merge_self": "A contact cannot be merged with itself",
|
||||
"settings.address_required": "Enter the installation address",
|
||||
"settings.enable_s3_first": "Switch on and save S3 storage first",
|
||||
"settings.entry_point_not_found": "Entry point not found",
|
||||
"settings.http_or_https": "http or https",
|
||||
"settings.integration_not_found": "Integration not found",
|
||||
"settings.invalid_address": "Invalid address",
|
||||
"settings.items_list_expected": "A list of items is expected",
|
||||
"settings.language_unsupported": "This language is not supported",
|
||||
"settings.migration_running": "A migration is already running",
|
||||
"settings.port_is_number": "The port is a number",
|
||||
"settings.s3_not_configured": "S3 is not configured",
|
||||
"settings.smtp_required_first": "Set the outgoing mail server first",
|
||||
"settings.socks_needs_pysocks": "A SOCKS proxy needs the PySocks dependency (pip install PySocks)",
|
||||
"settings.storages_missing": "django-storages[s3] is not installed",
|
||||
"settings.ttl_is_seconds": "The lifetime is a number of seconds",
|
||||
"settings.unknown_storage_kind": "Unknown storage type",
|
||||
"webchat.invalid_phone": "Invalid phone number",
|
||||
"webchat.no_active_invite": "No active invitation found",
|
||||
"webchat.session_not_found": "Session not found",
|
||||
"webchat.voice_off": "Voice messages are switched off",
|
||||
"webchat.widget_unavailable": "The widget is unavailable",
|
||||
"webchat.write_in_max": "Write on MAX",
|
||||
"webchat.write_in_telegram": "Write on Telegram",
|
||||
"audit.action_access_assignment_created": "Access assigned to an operator",
|
||||
"audit.action_administration_communication_updated": "Voice and calls changed",
|
||||
"audit.action_administration_instance_updated": "Platform settings changed",
|
||||
"audit.action_administration_logo_deleted": "Organization logo removed",
|
||||
"audit.action_administration_logo_updated": "Organization logo changed",
|
||||
"audit.action_administration_organization_updated": "Organization details changed",
|
||||
"audit.action_administration_storage_migration_requested": "File migration started",
|
||||
"audit.action_administration_storage_updated": "File storage changed",
|
||||
"audit.action_ai_agent_category_knowledge_selected": "Category knowledge selected for an agent",
|
||||
"audit.action_ai_agent_created": "Agent created",
|
||||
"audit.action_ai_agent_knowledge_attached": "Knowledge attached to an agent",
|
||||
"audit.action_ai_agent_knowledge_detached": "Knowledge detached from an agent",
|
||||
"audit.action_ai_agent_portal_articles_attached": "Portal articles attached to an agent",
|
||||
"audit.action_ai_agent_portal_articles_detached": "Portal articles detached from an agent",
|
||||
"audit.action_ai_agent_status_changed": "Agent status changed",
|
||||
"audit.action_ai_knowledge_attachment_added": "Knowledge attachment added",
|
||||
"audit.action_ai_knowledge_attachment_deleted": "Knowledge attachment deleted",
|
||||
"audit.action_ai_knowledge_category_created": "Knowledge category created",
|
||||
"audit.action_ai_knowledge_category_deleted": "Knowledge category deleted",
|
||||
"audit.action_ai_knowledge_category_updated": "Knowledge category changed",
|
||||
"audit.action_ai_knowledge_created": "Knowledge item added",
|
||||
"audit.action_ai_knowledge_deleted": "Knowledge item deleted",
|
||||
"audit.action_ai_knowledge_imported": "Knowledge imported",
|
||||
"audit.action_ai_knowledge_reindexed": "Knowledge item reindexed",
|
||||
"audit.action_ai_knowledge_updated": "Knowledge item changed",
|
||||
"audit.action_calls_cancelled": "Call cancelled",
|
||||
"audit.action_calls_requested": "Call requested",
|
||||
"audit.action_channels_channel_created": "Channel created",
|
||||
"audit.action_channels_channel_deleted": "Channel deleted",
|
||||
"audit.action_channels_channel_updated": "Channel changed",
|
||||
"audit.action_channels_connection_bound": "Connection bound to an agent",
|
||||
"audit.action_contacts_merged": "Contacts merged",
|
||||
"audit.action_contacts_unmerged": "Contacts unmerged",
|
||||
"audit.action_conversation_contact_updated": "Conversation contact changed",
|
||||
"audit.action_conversations_archived": "Conversation moved to the archive",
|
||||
"audit.action_conversations_assignee_changed": "Conversation assignee changed",
|
||||
"audit.action_conversations_claimed": "Conversation taken",
|
||||
"audit.action_conversations_closed": "Conversation closed",
|
||||
"audit.action_conversations_contact_requested": "Customer contact details requested",
|
||||
"audit.action_conversations_contact_updated": "Conversation contact changed",
|
||||
"audit.action_conversations_file_sent": "File sent to the conversation",
|
||||
"audit.action_conversations_group_changed": "Conversation group changed",
|
||||
"audit.action_conversations_marked_spam": "Conversation marked as spam",
|
||||
"audit.action_conversations_priority_changed": "Conversation priority changed",
|
||||
"audit.action_conversations_released_to_ai": "Conversation handed back to AI",
|
||||
"audit.action_conversations_returned_to_queue": "Conversation returned to the queue",
|
||||
"audit.action_conversations_unarchived": "Conversation restored from the archive",
|
||||
"audit.action_conversations_voice_sent": "Voice message sent to the conversation",
|
||||
"audit.action_demo_install_requested": "Demo data installation requested",
|
||||
"audit.action_demo_installed": "Demo data installed",
|
||||
"audit.action_demo_installed_accounts": "Demo operator accounts created",
|
||||
"audit.action_demo_remove_requested": "Demo data removal requested",
|
||||
"audit.action_demo_removed": "Demo data removed",
|
||||
"audit.action_identity_access_assignment_created": "Access assigned to an operator",
|
||||
"audit.action_identity_access_assignment_revoked": "Operator access revoked",
|
||||
"audit.action_identity_access_profile_created": "Access profile created",
|
||||
"audit.action_identity_access_profile_updated": "Access profile changed",
|
||||
"audit.action_identity_avatar_deleted": "Profile photo removed",
|
||||
"audit.action_identity_avatar_updated": "Profile photo changed",
|
||||
"audit.action_identity_employee_blocked": "Operator blocked",
|
||||
"audit.action_identity_employee_created": "Operator added",
|
||||
"audit.action_identity_employee_invited": "Operator invited",
|
||||
"audit.action_identity_invitation_accepted": "Organization invitation accepted",
|
||||
"audit.action_identity_invitation_revoked": "Operator invitation revoked",
|
||||
"audit.action_updates_install_requested": "Installation update started",
|
||||
"audit.action_identity_employee_groups_changed": "Operator groups changed",
|
||||
"audit.action_identity_employee_password_reset": "Operator password reset",
|
||||
"audit.action_identity_employee_privileged_action_denied": "Privileged action denied",
|
||||
"audit.action_identity_employee_role_changed": "Operator role changed",
|
||||
"audit.action_identity_employee_sessions_terminated": "Operator sessions ended",
|
||||
"audit.action_identity_employee_unblocked": "Operator unblocked",
|
||||
"audit.action_identity_employee_updated": "Operator changed",
|
||||
"audit.action_identity_group_created": "Group created",
|
||||
"audit.action_identity_group_deleted": "Group deleted",
|
||||
"audit.action_identity_group_updated": "Group changed",
|
||||
"audit.action_identity_instance_setup_completed": "First-run wizard completed",
|
||||
"audit.action_identity_login_failed": "Failed sign-in",
|
||||
"audit.action_identity_login_succeeded": "Signed in",
|
||||
"audit.action_identity_login_totp_required": "Two-factor code requested",
|
||||
"audit.action_identity_logout": "Signed out",
|
||||
"audit.action_identity_owner_bootstrapped": "Installation owner created",
|
||||
"audit.action_identity_ownership_transferred": "Organization ownership transferred",
|
||||
"audit.action_identity_password_reset_completed": "Password recovered",
|
||||
"audit.action_identity_password_reset_failed": "Failed password reset",
|
||||
"audit.action_identity_password_reset_requested": "Password reset requested",
|
||||
"audit.action_identity_profile_language_changed": "Interface language changed",
|
||||
"audit.action_identity_profile_password_changed": "Password changed",
|
||||
"audit.action_identity_profile_sessions_revoked": "Own sessions ended",
|
||||
"audit.action_identity_profile_totp_disabled": "Two-factor authentication turned off",
|
||||
"audit.action_identity_profile_totp_setup_started": "Two-factor setup started",
|
||||
"audit.action_identity_profile_updated": "Operator profile changed",
|
||||
"audit.action_identity_temporary_password_changed": "Temporary password changed",
|
||||
"audit.action_identity_totp_enabled": "Two-factor authentication turned on",
|
||||
"audit.action_identity_totp_setup_failed": "Two-factor setup failed",
|
||||
"audit.action_identity_totp_verify_failed": "Wrong two-factor code",
|
||||
"audit.action_integrations_integration_created": "Integration added",
|
||||
"audit.action_integrations_integration_deleted": "Integration deleted",
|
||||
"audit.action_integrations_integration_updated": "Integration changed",
|
||||
"audit.action_organization_owner_activated": "Organization owner activated",
|
||||
"audit.action_organization_owner_invitation_requested": "Owner invitation sent",
|
||||
"audit.action_organization_provisioned": "Organization created",
|
||||
"audit.action_support_portal_articles_imported": "Portal articles imported",
|
||||
"audit.action_support_portal_created": "Portal created",
|
||||
"audit.action_support_portal_draft": "Portal unpublished",
|
||||
"audit.action_support_portal_published": "Portal published",
|
||||
"audit.category_access_assignment": "Access and operators",
|
||||
"audit.category_administration": "Settings",
|
||||
"audit.category_ai": "AI and knowledge",
|
||||
"audit.category_calls": "Calls",
|
||||
"audit.category_channels": "Channels",
|
||||
"audit.category_contacts": "Contacts",
|
||||
"audit.category_conversation": "Conversations",
|
||||
"audit.category_conversations": "Conversations",
|
||||
"audit.category_demo": "Demo data",
|
||||
"audit.category_identity": "Access and operators",
|
||||
"audit.category_integrations": "Integrations",
|
||||
"audit.category_organization": "Organization",
|
||||
"audit.category_support_portal": "Portals",
|
||||
"audit.object_accessassignment": "Access assignment",
|
||||
"audit.object_accessprofile": "Access profile",
|
||||
"audit.object_agentcard": "Agent",
|
||||
"audit.object_channel": "Channel",
|
||||
"audit.object_contact": "Contact",
|
||||
"audit.object_conversation": "Conversation",
|
||||
"audit.object_employeegroup": "Group",
|
||||
"audit.object_humanuser": "Operator",
|
||||
"audit.object_instancesettings": "Installation",
|
||||
"audit.object_integration": "Integration",
|
||||
"audit.object_knowledge": "Knowledge item",
|
||||
"audit.object_knowledgecategory": "Knowledge category",
|
||||
"audit.object_organization": "Organization",
|
||||
"audit.object_storagesettings": "Storage",
|
||||
"audit.object_supportportal": "Portal",
|
||||
"audit.result_denied": "Denied",
|
||||
"audit.result_failed": "Failed",
|
||||
"audit.result_success": "Succeeded",
|
||||
"audit.category_other": "Other",
|
||||
"audit.result_unknown": "Unknown",
|
||||
"ai.agent_code_collision": "Could not find a unique agent code",
|
||||
"ai.pick_provider_first": "Pick the organization's provider to start AI",
|
||||
"calls.already_ended": "The call has already ended",
|
||||
"calls.cannot_cancel": "The call can no longer be cancelled",
|
||||
"calls.conversation_outside_groups": "The conversation is outside the operator's groups",
|
||||
"calls.conversation_taken": "Another operator is already handling this conversation",
|
||||
"calls.identity_missing_or_ambiguous": "The call's customer identity is missing or ambiguous",
|
||||
"calls.invite_cannot_accept": "The invitation can no longer be accepted",
|
||||
"calls.invite_cannot_decline": "The invitation can no longer be declined",
|
||||
"calls.no_access": "No access to calls",
|
||||
"calls.no_client_identity": "The conversation has no customer identity to call",
|
||||
"calls.not_a_participant": "The operator is not a participant of this call",
|
||||
"calls.only_in_open_conversation": "A call can only be requested in an open conversation",
|
||||
"calls.operator_busy": "The operator is already on another call",
|
||||
"calls.operator_context_required": "A call needs an operator context",
|
||||
"calls.second_call_rejected": "A second unfinished call could not be created",
|
||||
"calls.transition_forbidden": "Transition {current} -> {target} is not allowed",
|
||||
"calls.unfinished_call_exists": "The conversation already has an unfinished call",
|
||||
"channels.no_active_agent": "The channel has no active AI agent",
|
||||
"conversations.unavailable": "The conversation is unavailable",
|
||||
"admin.action_not_allowed_for_employee": "You cannot perform this action on this employee",
|
||||
"admin.email_required": "Email is required",
|
||||
"admin.email_taken": "Email is already used",
|
||||
"admin.full_name_required": "Full name is required",
|
||||
"admin.invalid_role": "Invalid role",
|
||||
"admin.not_allowed": "Not allowed",
|
||||
"admin.previous_owner_role": "Previous owner role must be ADMIN or EMPLOYEE",
|
||||
"admin.target_must_be_active": "Target must be an active employee",
|
||||
"admin.temporary_passwords_unsupported": "Temporary passwords are not supported",
|
||||
"admin.unknown_password_mode": "Unknown password mode",
|
||||
"ai.attachment_not_found": "Attachment not found",
|
||||
"ai.category_not_found": "Category not found",
|
||||
"ai.documents_non_empty": "documents must be a non-empty list",
|
||||
"ai.file_required": "file is required (multipart/form-data)",
|
||||
"ai.group_id_integer_or_null": "groupId must be an integer or null",
|
||||
"ai.group_id_or_none": "group must be an id or none",
|
||||
"ai.history_must_be_list": "history must be a list",
|
||||
"ai.integration_id_integer": "integrationId must be an integer",
|
||||
"ai.knowledge_not_found": "Knowledge not found",
|
||||
"ai.title_required": "Title is required",
|
||||
"conversations.archived_boolean": "archived must be a boolean",
|
||||
"conversations.label_ids_list": "labelIds must be a list of ids",
|
||||
"identity.invalid_credentials": "Invalid credentials",
|
||||
"identity.invalid_totp_code": "Invalid TOTP code",
|
||||
"identity.invitation_email_mismatch": "Invitation email does not match the account",
|
||||
"identity.invitation_invalid": "Invitation is invalid or has expired",
|
||||
"identity.invitation_account_exists": "An account with this address already exists. Sign in with it",
|
||||
"updates.nothing_to_install": "The latest version is already installed",
|
||||
"updates.updater_offline": "The update service is not running, so updating from the interface is unavailable",
|
||||
"updates.install_in_progress": "An update is already in progress",
|
||||
"identity.role_conflict": "User already has a different role in this organization",
|
||||
"identity.token_required": "token is required",
|
||||
"identity.totp_challenge_inactive": "TOTP challenge is not active",
|
||||
"notifications.ids_list_or_all": "ids must be a list or use all=true",
|
||||
"notifications.push_types_list": "pushTypes must be a list",
|
||||
"platform.idempotency_key_required": "Idempotency-Key header is required",
|
||||
"portals.articles_non_empty": "articles must be a non-empty list",
|
||||
"profile.current_password_invalid": "Current password is invalid",
|
||||
"profile.totp_enabled_already": "TOTP is already enabled",
|
||||
"profile.totp_required_by_policy": "TOTP is required by an organization policy",
|
||||
"ai.provider_error": "Provider error: {error}",
|
||||
"conversations.activity_closed": "Conversation closed · {channel}",
|
||||
"conversations.activity_started": "Conversation · {channel}",
|
||||
"conversations.field_too_long": "Field {field}: no longer than {limit} characters",
|
||||
"ai.provider_no_transcription": "The {provider} provider does not support audio transcription",
|
||||
"ai.transcription_failed": "Transcription failed: {error}",
|
||||
"ai.transcription_failed_http": "Transcription failed: HTTP {code} {detail}",
|
||||
"api.expected_integer": "{name}: an integer is expected",
|
||||
"api.expected_positive": "{name}: a number greater than zero is expected",
|
||||
"api.expected_record_id": "{name}: a record identifier is expected",
|
||||
"settings.storage_unavailable": "The storage is unavailable: {error}",
|
||||
"admin.actor_system": "System",
|
||||
"admin.image_formats": "PNG, JPEG, WebP and SVG are supported",
|
||||
"admin.svg_logo_unsafe": "SVG must not contain scripts, event handlers or external links",
|
||||
"admin.logo_too_large": "The logo must not exceed 2 MB",
|
||||
"calls.calls_off_entry_point": "Calls are switched off for this entry point",
|
||||
"calls.failure_code_required": "FAILED needs a normalized failure_code",
|
||||
"calls.video_off_entry_point": "Video calls are switched off for this entry point",
|
||||
"channels.archived_channel_binding": "A connection cannot be bound to an archived channel",
|
||||
"channels.connection_not_bound": "The connection is not bound to a channel",
|
||||
"channels.connection_not_found": "Connection not found",
|
||||
"channels.llm_is_not_a_connection": "An LLM provider is not a channel connection",
|
||||
"channels.name_required": "The channel name cannot be empty",
|
||||
"channels.name_too_long": "The channel name is longer than 255 characters",
|
||||
"portals.a_record_mismatch": "The domain's A record does not point at the Chatballs server",
|
||||
"portals.a_record_missing": "The domain's A record has not appeared yet",
|
||||
"portals.anonymous_widget_missing": "No active anonymous support web widget was found",
|
||||
"portals.archived_article_publish": "An archived article cannot be published",
|
||||
"portals.archived_article_readonly": "An archived article cannot be edited",
|
||||
"portals.domain_empty_check": "An empty domain cannot be verified",
|
||||
"portals.domain_is_internal": "The internal application domain cannot be used",
|
||||
"portals.domain_required_first": "Set the domain first",
|
||||
"portals.domain_same_as_installation": "A custom domain must differ from the Chatballs address",
|
||||
"portals.domain_taken": "This domain is already used by another portal",
|
||||
"portals.file_name_required": "The file name is required",
|
||||
"portals.file_too_large": "The file is over 25 MB",
|
||||
"portals.invalid_theme_id": "Invalid theme identifier",
|
||||
"portals.invalid_theme_scheme": "Unknown theme colour scheme",
|
||||
"portals.invalid_theme_setting_name": "Invalid theme setting name",
|
||||
"portals.move_articles_first": "Move the articles out of this section first",
|
||||
"portals.move_nested_sections_first": "Move or delete the nested sections first",
|
||||
"portals.other_organization": "The portal belongs to another organization",
|
||||
"portals.restore_to_edit_content": "Restore the portal to change its content",
|
||||
"portals.restore_to_edit_domain": "Restore the portal to change its domain",
|
||||
"portals.restore_to_edit_settings": "Restore the portal to change its settings",
|
||||
"portals.restore_to_verify_domain": "Restore the portal to verify its domain",
|
||||
"portals.revision_not_found": "Article revision not found",
|
||||
"portals.slug_taken": "This address is already used by another portal",
|
||||
"portals.theme_settings_json": "Theme settings must be a JSON object",
|
||||
"portals.theme_settings_object": "Theme settings must be an object",
|
||||
"portals.theme_settings_too_large": "The theme settings are too large",
|
||||
"portals.unknown_status": "Unknown portal status",
|
||||
"portals.widget_channel_missing": "The web widget channel was not found",
|
||||
"profile.photo_too_large": "The photo must not exceed 2 MB",
|
||||
"settings.local_or_s3": "LOCAL or S3",
|
||||
"settings.object_written_not_readable": "The object was written but cannot be read — check the read permissions",
|
||||
"settings.seconds_number": "A number of seconds",
|
||||
"admin.group_name_required": "Group name is required",
|
||||
"ai.agent_other_organization": "Agent belongs to another organization",
|
||||
"ai.archived_agent_state": "Archived AI agent cannot change state",
|
||||
"ai.at_least_one_knowledge_id": "At least one knowledge ID is required",
|
||||
"ai.attachment_other_organization": "Attachment belongs to another organization",
|
||||
"ai.category_cycle": "Category cycle is not allowed",
|
||||
"ai.category_name_required": "Category name is required",
|
||||
"ai.category_names_strings": "Category names must be non-empty strings",
|
||||
"ai.category_other_organization": "Category belongs to another organization",
|
||||
"ai.category_path_not_found": "Category path not found",
|
||||
"ai.category_path_required": "Non-empty category path required",
|
||||
"ai.category_with_children": "Category with children cannot be deleted",
|
||||
"ai.category_with_knowledge": "Category with knowledge cannot be deleted",
|
||||
"ai.channel_required": "Channel is required",
|
||||
"ai.content_required": "Content string is required",
|
||||
"ai.description_string": "Description must be a string",
|
||||
"ai.document_object_required": "Document must be an object",
|
||||
"ai.expected_attach_or_detach": "Expected attach or detach",
|
||||
"ai.file_name_required": "File name is required",
|
||||
"ai.file_too_large_25": "File is too large (max 25 MB)",
|
||||
"ai.integration_model_required": "Integration default model is required",
|
||||
"ai.knowledge_ids_integers": "Knowledge IDs must be integers",
|
||||
"ai.knowledge_other_organization": "Knowledge belongs to another organization",
|
||||
"ai.knowledge_unknown_or_unavailable": "Unknown or unavailable knowledge item",
|
||||
"ai.list_of_ids": "List of IDs required",
|
||||
"ai.list_of_knowledge_ids": "List of knowledge IDs required",
|
||||
"ai.parent_category_not_found": "Parent category not found",
|
||||
"ai.parent_category_other_organization": "Parent category belongs to another organization",
|
||||
"ai.system_category_immutable": "System category is immutable",
|
||||
"ai.system_category_name_immutable": "System category name is immutable",
|
||||
"ai.system_category_undeletable": "System category cannot be deleted",
|
||||
"ai.unknown_knowledge_item": "Unknown knowledge item",
|
||||
"ai.unknown_portal_article": "Unknown portal article",
|
||||
"ai.unknown_provider_integration": "Unknown OpenRouter or Custom integration",
|
||||
"api.at_least_one_id": "At least one ID is required",
|
||||
"api.boolean_required": "Boolean required",
|
||||
"api.integer_id_required": "Integer id required",
|
||||
"api.integer_required": "Integer required",
|
||||
"api.list_of_ids_required": "List of ids required",
|
||||
"api.object_required": "Object required",
|
||||
"api.positive_id_required": "Positive integer ID required",
|
||||
"channels.agent_exists": "Channel already has an AI agent",
|
||||
"channels.not_found": "Channel not found",
|
||||
"channels.unknown_group": "Unknown group",
|
||||
"conversations.file_download_unsupported": "File download is not supported for this provider",
|
||||
"conversations.telegram_getfile_failed": "Telegram getFile failed",
|
||||
"conversations.voice_download_unsupported": "Voice download is not supported for this provider",
|
||||
"portals.article_object_required": "Article must be an object",
|
||||
"portals.locale_string": "Locale must be a string",
|
||||
"portals.summary_string": "Summary must be a string",
|
||||
"portals.widget_channel_mismatch": "Widget and legacy channel must match",
|
||||
"portals.widget_needs_anonymous": "Portal widget must allow anonymous web sessions",
|
||||
"portals.widget_needs_channel": "Portal widget must be bound to a channel",
|
||||
"settings.allowed_origins_list": "allowedOrigins must be a list of strings",
|
||||
"settings.api_key_required": "Custom API key is required",
|
||||
"settings.custom_base_url_required": "Custom Base URL is required",
|
||||
"settings.custom_model_required": "Custom model is required",
|
||||
"settings.email_hosts_required": "Email address, IMAP host and SMTP host are required",
|
||||
"settings.email_not_a_bot": "Email cannot be a notifications bot",
|
||||
"settings.inactive_channel": "Inactive channel cannot accept connections",
|
||||
"settings.integration_other_organization": "Integration belongs to another organization",
|
||||
"settings.mailbox_password_required": "Mailbox password is required",
|
||||
"settings.name_required": "Name required",
|
||||
"settings.ports_numbers": "Ports must be numbers",
|
||||
"settings.quick_replies_list": "quickReplies must be a list of strings",
|
||||
"settings.unknown_provider": "Unknown provider",
|
||||
"ai.field_required": "The field “{field}” is required",
|
||||
"ai.unexpected_provider_response": "Unexpected provider response: {error}",
|
||||
"settings.base_url_rejected": "Base URL: {error}",
|
||||
"settings.proxy_url_rejected": "Proxy: {error}",
|
||||
"notifications.type_dialog_waiting": "A dialog is waiting for an operator",
|
||||
"notifications.type_dialog_new_message": "A new message in a dialog",
|
||||
"notifications.type_release_published": "An agent release was published",
|
||||
"notifications.type_integration_error": "Integration error",
|
||||
"integrations.check_no_connection": "No connection: {error}",
|
||||
"integrations.check_api_key_missing": "The API key is not set",
|
||||
"integrations.check_base_url_missing": "Base URL is not set",
|
||||
"integrations.check_bot_token_missing": "The bot token is not set",
|
||||
"integrations.check_mailbox_password_missing": "The mailbox password is not set",
|
||||
"integrations.check_mail_hosts_missing": "The address, IMAP host or SMTP host is missing",
|
||||
"integrations.check_provider_answered": "{provider} answered {status}",
|
||||
"integrations.check_endpoint_answered": "The endpoint answered {status}",
|
||||
"integrations.check_key_accepted": "the key was accepted",
|
||||
"integrations.check_bot_connected": "the bot is connected",
|
||||
"integrations.check_demo": "Demo provider: answers from the agent's knowledge, with no external calls or keys",
|
||||
"integrations.check_unsupported": "Checking is not supported for this connection type",
|
||||
"integrations.check_web_not_bound": "The connection is not bound to a channel — the widget is inactive",
|
||||
"integrations.check_web_no_config": "The web widget configuration has not been created",
|
||||
"integrations.check_web_no_origins": "No allowed domains are set — the widget will be unavailable on the site",
|
||||
"integrations.check_web_active": "The web widget is active · channel “{channel}”",
|
||||
"integrations.check_endpoint_models": {"one": "The endpoint answers: {count} model", "other": "The endpoint answers: {count} models"},
|
||||
"identity.password_help": "The password must contain a letter, a digit and a special character.",
|
||||
"conversations.attachment_too_large": "The file is over 20 MB",
|
||||
"conversations.attachment_empty": "The file is empty",
|
||||
"conversations.attachment_type_blocked": "This file type cannot be sent",
|
||||
"settings.s3_bucket_required": "Enter the bucket name",
|
||||
"settings.s3_endpoint_scheme": "The address must start with http:// or https://",
|
||||
"settings.s3_access_key_required": "Enter the Access Key",
|
||||
"settings.s3_secret_key_required": "Enter the Secret Key",
|
||||
"channels.binding_conflict": "The connection is already bound to another channel: moving it requires force",
|
||||
"channels.operation_needs_manage": "{operation} requires channels.manage",
|
||||
"channels.operation_status_change": "Changing the channel status",
|
||||
"channels.operation_policy_change": "Changing the channel policy",
|
||||
"conversations.dialog_status_closed": "Closed",
|
||||
"conversations.dialog_status_operator": "Operator",
|
||||
"conversations.dialog_status_wait": "Waiting for an operator",
|
||||
"setup.your_name_required": "Enter your name",
|
||||
"setup.your_name_too_long": "The name is longer than 255 characters",
|
||||
"setup.email_invalid": "Enter a valid e-mail address",
|
||||
"setup.check_fields": "Check the fields you filled in",
|
||||
"setup.owner_position": "Owner",
|
||||
"ai.agent_without_llm_integration": "The agent is not bound to a BYOK LLM integration; choose a provider in the agent settings",
|
||||
"ai.integration_is_not_llm": "The “{provider}” integration is not a BYOK LLM provider",
|
||||
"settings.url_scheme_required": "The address must start with one of these schemes: {schemes}",
|
||||
"settings.download_http_only": "Downloads are only allowed over http/https",
|
||||
"settings.url_points_inside": "The address points into the internal network",
|
||||
"settings.scheme_forbidden": "The {scheme} scheme is not allowed in outgoing requests",
|
||||
"conversations.contact_request": "Please share your contact — tap the button below.",
|
||||
"conversations.contact_request_web": "Please share your phone number.",
|
||||
"calls.invite_text": "You are invited to {kind}. Tap the button to join the call.",
|
||||
"conversations.email_default_subject": "Your request",
|
||||
"conversations.voice_message": "Voice message",
|
||||
"conversations.file_caption": "File: {name}",
|
||||
"webchat.default_greeting": "Hello! Happy to help and answer your questions. What can we do for you?",
|
||||
"webchat.default_consent": "By continuing you agree that your messages are processed to answer your request.",
|
||||
"webchat.guest_name": "Guest · {code}",
|
||||
"channels.operation_agent_create": "Creating an agent",
|
||||
"channels.operation_agent_delete": "Deleting an agent",
|
||||
"identity.browser_unknown": "Browser",
|
||||
"settings.mail_test_subject": "Chatballs mail check",
|
||||
"settings.mail_test_body": "This is a test message from Settings. If you are reading it, the installation's mail works.",
|
||||
"notifications.binding_done": "Done! Chatballs notifications are connected. You can turn them off in your profile.",
|
||||
"notifications.binding_hint": "This is the Chatballs notification bot. To connect notifications, open your profile in Chatballs and press “Connect”.",
|
||||
"conversations.button_share_contact": "Share contact",
|
||||
"conversations.button_join_call": "Join the call",
|
||||
"ai.demo_handover": "I'll check this with my colleagues and hand the dialog to a member of staff — they will answer during working hours.",
|
||||
"ai.demo_handover_suffix": " I'm handing the dialog to a member of staff — they will help from here.",
|
||||
"ai.unknown_answer_language": "Unknown agent answer language",
|
||||
}
|
||||
@@ -0,0 +1,549 @@
|
||||
"""Русский каталог — исходный. Ключ появляется здесь, потом в en.py.
|
||||
|
||||
Ключи сгруппированы по модулю, которому принадлежит строка, и внутри группы
|
||||
идут по алфавиту: искать строку приходится по коду, а не по фразе.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
MESSAGES: dict[str, object] = {
|
||||
"admin.choose_logo_file": "Выберите файл логотипа",
|
||||
"admin.currency_rub_only": "Поддерживается только российский рубль (RUB)",
|
||||
"admin.employee_not_found": "Сотрудник не найден",
|
||||
"admin.group_name_taken": "Группа с таким именем уже есть",
|
||||
"admin.group_not_found": "Группа не найдена",
|
||||
"admin.invalid_timezone": "Укажите корректный часовой пояс",
|
||||
"admin.logo_not_uploaded": "Логотип не загружен",
|
||||
"admin.multi_org_self_reset": "Сотрудник состоит в нескольких организациях — пароль сбрасывает он сам через восстановление доступа",
|
||||
"admin.name_too_long": "Название не должно превышать 255 символов",
|
||||
"admin.organization_name_required": "Укажите название организации",
|
||||
"admin.photo_not_found": "Фото не найдено",
|
||||
"ai.agent_not_found": "Агент не найден",
|
||||
"ai.demo_no_transcription": "Расшифровка голосовых недоступна в демо-провайдере: подключите OpenRouter или совместимый провайдер в «Настройках».",
|
||||
"ai.empty_message": "Пустое сообщение",
|
||||
"ai.empty_transcript": "Провайдер вернул пустую расшифровку",
|
||||
"ai.provider_not_configured": "AI-провайдер не настроен: вызов без канала не может выбрать интеграцию",
|
||||
"api.window_needs_sort_key": "окно требует хотя бы один ключ сортировки",
|
||||
"calls.invite_invalid": "Недействительное или истёкшее приглашение",
|
||||
"calls.kind_audio": "аудиозвонок",
|
||||
"calls.kind_invalid": "Недопустимый тип звонка",
|
||||
"calls.kind_required": "Укажите тип звонка",
|
||||
"calls.kind_video": "видеозвонок",
|
||||
"calls.not_found": "Звонок не найден",
|
||||
"calls.token_invalid": "Недействительный или истёкший call access token",
|
||||
"channels.binding_needs_manage": "Привязка подключения требует integrations.manage",
|
||||
"channels.cannot_delete_linked": "Канал нельзя удалить: есть связанные записи",
|
||||
"channels.no_rights_to_change": "Нет прав на изменение канала",
|
||||
"conversations.archive_admin_only": "Архив доступен администраторам",
|
||||
"conversations.attach_audio": "Прикрепите аудио",
|
||||
"conversations.attach_file": "Прикрепите файл",
|
||||
"conversations.audio_format_unsupported": "Неподдерживаемый формат аудио",
|
||||
"conversations.audio_too_large": "Аудио больше 10 МБ",
|
||||
"conversations.audio_unavailable": "Аудио недоступно",
|
||||
"conversations.closed": "Диалог закрыт",
|
||||
"conversations.contact_already_received": "Контакт уже получен",
|
||||
"conversations.contact_name_empty": "Имя контакта не может быть пустым",
|
||||
"conversations.file_send_failed": "Не удалось отправить файл в канал",
|
||||
"conversations.file_too_large": "Файл больше допустимого размера",
|
||||
"conversations.file_unavailable": "Файл недоступен",
|
||||
"conversations.files_unavailable_channel": "Файлы недоступны в этом канале",
|
||||
"conversations.guest": "Гость",
|
||||
"conversations.handled_by_other": "Диалог ведёт другой оператор",
|
||||
"conversations.invalid_colour": "Некорректный цвет",
|
||||
"conversations.label_name_length": "Название метки: 1-60 символов",
|
||||
"conversations.label_not_found": "Метка не найдена",
|
||||
"conversations.message_not_found": "Сообщение не найдено",
|
||||
"conversations.no_channel_for_contact": "У диалога нет канала для запроса контакта",
|
||||
"conversations.no_contact": "У диалога нет контакта",
|
||||
"conversations.not_a_voice_message": "Это не голосовое сообщение",
|
||||
"conversations.not_found": "Диалог не найден",
|
||||
"conversations.note_too_long": "Заметка длиннее 4000 символов",
|
||||
"conversations.restore_admin_only": "Восстановить диалог может только администратор",
|
||||
"conversations.system.ai_handed_over": "AI передал диалог оператору",
|
||||
"conversations.system.ai_unavailable": "AI недоступен — диалог передан оператору",
|
||||
"conversations.system.call_accepted": "Клиент принял приглашение на звонок",
|
||||
"conversations.system.call_cancelled": "Сотрудник отменил приглашение на звонок",
|
||||
"conversations.system.call_declined": "Клиент отклонил приглашение на звонок",
|
||||
"conversations.system.call_ended": "Звонок завершён · {duration}",
|
||||
"conversations.system.call_expired": "Приглашение на звонок истекло",
|
||||
"conversations.system.call_failed": "Звонок не состоялся",
|
||||
"conversations.system.call_missed": "Звонок пропущен: клиент не ответил",
|
||||
"conversations.system.call_requested": "Оператор {operator} запросил {kind}",
|
||||
"conversations.system.call_started": "Звонок начался: соединение установлено",
|
||||
"conversations.system.operator_took": "Оператор {operator} перехватил диалог",
|
||||
"conversations.system.returned_to_ai": "Диалог возвращён AI",
|
||||
"conversations.system.returned_to_queue": "Диалог возвращён в очередь",
|
||||
"conversations.template_name_length": "Название шаблона: 1-120 символов",
|
||||
"conversations.template_name_taken": "Шаблон с таким названием уже есть",
|
||||
"conversations.template_not_found": "Шаблон не найден",
|
||||
"conversations.template_text_required": "Текст шаблона обязателен",
|
||||
"conversations.unknown_label": "Неизвестная метка",
|
||||
"conversations.unknown_priority": "Неизвестный приоритет",
|
||||
"conversations.voice_off_entry_point": "Голосовые отключены для этой точки входа",
|
||||
"conversations.voice_send_failed": "Не удалось отправить голосовое в канал",
|
||||
"conversations.voice_unavailable_channel": "Голосовые сообщения недоступны в этом канале",
|
||||
"emails.initial_access_body": "Здравствуйте, {name}.\n\nДля вас создана учётная запись Chatballs. Чтобы задать пароль первичного доступа, перейдите по ссылке:\n{url}\n\nСсылка действует 30 минут. Если вы не ожидали это письмо, обратитесь к владельцу организации.",
|
||||
"emails.initial_access_subject": "Первичный доступ к Chatballs",
|
||||
"emails.password_reset_body": "Здравствуйте, {name}.\n\nВы запросили сброс пароля для Chatballs. Чтобы задать новый пароль, перейдите по ссылке:\n{url}\n\nСсылка действует 30 минут. Если вы не запрашивали сброс, просто проигнорируйте это письмо.",
|
||||
"emails.password_reset_subject": "Восстановление доступа к Chatballs",
|
||||
"emails.membership_invitation_subject": "Приглашение в организацию {organization}",
|
||||
"emails.membership_invitation_body": "Здравствуйте, {name}.\n\nВас приглашают в организацию «{organization}» в Chatballs. Чтобы принять приглашение, перейдите по ссылке:\n{url}\n\nСсылка действует 7 дней. Если вы не ожидали это письмо, просто проигнорируйте его.",
|
||||
"identity.link_invalid_or_expired": "Ссылка недействительна или истекла",
|
||||
"identity.password_needs_digit": "Пароль должен содержать цифру.",
|
||||
"identity.password_needs_letter": "Пароль должен содержать букву.",
|
||||
"identity.password_needs_special": "Пароль должен содержать спецсимвол.",
|
||||
"identity.setup_already_done": "Первый запуск уже выполнен: организация создана",
|
||||
"notifications.ai_error": "Ошибка AI · {channel}",
|
||||
"notifications.ai_handed_over": "AI передал диалог · {contact}",
|
||||
"notifications.ai_unavailable_handed_over": "AI временно недоступен, диалог передан оператору",
|
||||
"notifications.ai_unavailable_waiting": "AI временно недоступен, диалог ждёт ответа",
|
||||
"notifications.new_dialog": "Новый диалог · {channel}",
|
||||
"notifications.new_dialog_body": "{contact} · {provider}: {preview}",
|
||||
"notifications.new_message": "Новое сообщение · {contact}",
|
||||
"notifications.operator_needed": "Нужен оператор · {contact}",
|
||||
"notifications.voice_without_transcript": "Голосовое без расшифровки",
|
||||
"portals.article_not_found": "Статья не найдена",
|
||||
"portals.file_field_required": "Нужен файл в поле file (multipart/form-data)",
|
||||
"portals.file_not_found": "Файл не найден",
|
||||
"portals.helpful_must_be_boolean": "helpful должен быть boolean",
|
||||
"portals.invalid_article": "Некорректная статья",
|
||||
"portals.invalid_article_data": "Некорректные данные статьи",
|
||||
"portals.invalid_category": "Некорректная категория",
|
||||
"portals.invalid_domain": "Некорректное доменное имя",
|
||||
"portals.invalid_pagination": "Некорректная пагинация",
|
||||
"portals.invalid_revision": "Некорректная версия",
|
||||
"portals.invalid_section": "Некорректный раздел",
|
||||
"portals.not_found": "Портал не найден",
|
||||
"portals.section_not_found": "Раздел не найден",
|
||||
"profile.accent_hex": "Акцент — HEX-цвет вида #1677ff",
|
||||
"profile.choose_photo_file": "Выберите файл фото",
|
||||
"profile.link_not_found": "Привязка не найдена",
|
||||
"profile.notification_bot_not_found": "Бот уведомлений не найден",
|
||||
"profile.photo_not_set": "Фото не задано",
|
||||
"profile.totp_already_on": "TOTP уже включена: сначала отключите её текущим паролем",
|
||||
"profile.unknown_theme": "Неизвестная тема",
|
||||
"sales.already_merged": "Контакт уже объединён с другим — сначала разъедините",
|
||||
"sales.already_unmerged": "Это объединение уже разъединено",
|
||||
"sales.client_not_found": "Клиент не найден",
|
||||
"sales.contact_not_found": "Контакт не найден",
|
||||
"sales.merge_not_found": "Объединение не найдено",
|
||||
"sales.merge_owner_only": "Объединять контакты может только владелец",
|
||||
"sales.merge_reason_required": "Укажите причину объединения — она попадёт в журнал действий",
|
||||
"sales.merge_self": "Нельзя объединить контакт с самим собой",
|
||||
"settings.address_required": "Укажите адрес установки",
|
||||
"settings.enable_s3_first": "Сначала включите и сохраните S3-хранилище",
|
||||
"settings.entry_point_not_found": "Точка входа не найдена",
|
||||
"settings.http_or_https": "http или https",
|
||||
"settings.integration_not_found": "Интеграция не найдена",
|
||||
"settings.invalid_address": "Некорректный адрес",
|
||||
"settings.items_list_expected": "Ожидается список items",
|
||||
"settings.language_unsupported": "Этот язык не поддерживается",
|
||||
"settings.migration_running": "Перенос уже идёт",
|
||||
"settings.port_is_number": "Порт — число",
|
||||
"settings.s3_not_configured": "S3 не настроено",
|
||||
"settings.smtp_required_first": "Сначала укажите сервер исходящей почты",
|
||||
"settings.socks_needs_pysocks": "Для SOCKS-прокси нужна зависимость PySocks (pip install PySocks)",
|
||||
"settings.storages_missing": "django-storages[s3] не установлен",
|
||||
"settings.ttl_is_seconds": "Время жизни — число секунд",
|
||||
"settings.unknown_storage_kind": "Неизвестный тип хранилища",
|
||||
"webchat.invalid_phone": "Некорректный номер телефона",
|
||||
"webchat.no_active_invite": "Активное приглашение не найдено",
|
||||
"webchat.session_not_found": "Сессия не найдена",
|
||||
"webchat.voice_off": "Голосовые отключены",
|
||||
"webchat.widget_unavailable": "Виджет недоступен",
|
||||
"webchat.write_in_max": "Написать в MAX",
|
||||
"webchat.write_in_telegram": "Написать в Telegram",
|
||||
"audit.action_access_assignment_created": "Назначен доступ сотруднику",
|
||||
"audit.action_administration_communication_updated": "Изменены голосовые и звонки",
|
||||
"audit.action_administration_instance_updated": "Изменены настройки платформы",
|
||||
"audit.action_administration_logo_deleted": "Удалён логотип организации",
|
||||
"audit.action_administration_logo_updated": "Изменён логотип организации",
|
||||
"audit.action_administration_organization_updated": "Изменены данные организации",
|
||||
"audit.action_administration_storage_migration_requested": "Запущен перенос файлов",
|
||||
"audit.action_administration_storage_updated": "Изменено хранилище файлов",
|
||||
"audit.action_ai_agent_category_knowledge_selected": "Выбраны знания категории для агента",
|
||||
"audit.action_ai_agent_created": "Создан агент",
|
||||
"audit.action_ai_agent_knowledge_attached": "Знания подключены к агенту",
|
||||
"audit.action_ai_agent_knowledge_detached": "Знания отключены от агента",
|
||||
"audit.action_ai_agent_portal_articles_attached": "Статьи портала подключены к агенту",
|
||||
"audit.action_ai_agent_portal_articles_detached": "Статьи портала отключены от агента",
|
||||
"audit.action_ai_agent_status_changed": "Изменён статус агента",
|
||||
"audit.action_ai_knowledge_attachment_added": "Добавлено вложение знания",
|
||||
"audit.action_ai_knowledge_attachment_deleted": "Удалено вложение знания",
|
||||
"audit.action_ai_knowledge_category_created": "Создана категория знаний",
|
||||
"audit.action_ai_knowledge_category_deleted": "Удалена категория знаний",
|
||||
"audit.action_ai_knowledge_category_updated": "Изменена категория знаний",
|
||||
"audit.action_ai_knowledge_created": "Добавлено знание",
|
||||
"audit.action_ai_knowledge_deleted": "Удалено знание",
|
||||
"audit.action_ai_knowledge_imported": "Знания импортированы",
|
||||
"audit.action_ai_knowledge_reindexed": "Знание переиндексировано",
|
||||
"audit.action_ai_knowledge_updated": "Изменено знание",
|
||||
"audit.action_calls_cancelled": "Звонок отменён",
|
||||
"audit.action_calls_requested": "Запрошен звонок",
|
||||
"audit.action_channels_channel_created": "Создан канал",
|
||||
"audit.action_channels_channel_deleted": "Удалён канал",
|
||||
"audit.action_channels_channel_updated": "Изменён канал",
|
||||
"audit.action_channels_connection_bound": "Подключение привязано к агенту",
|
||||
"audit.action_contacts_merged": "Объединение контактов",
|
||||
"audit.action_contacts_unmerged": "Разъединение контактов",
|
||||
"audit.action_conversation_contact_updated": "Изменён контакт диалога",
|
||||
"audit.action_conversations_archived": "Диалог отправлен в архив",
|
||||
"audit.action_conversations_assignee_changed": "Изменён ответственный за диалог",
|
||||
"audit.action_conversations_claimed": "Диалог взят в работу",
|
||||
"audit.action_conversations_closed": "Диалог закрыт",
|
||||
"audit.action_conversations_contact_requested": "Запрошены контакты клиента",
|
||||
"audit.action_conversations_contact_updated": "Изменён контакт диалога",
|
||||
"audit.action_conversations_file_sent": "Отправлен файл в диалог",
|
||||
"audit.action_conversations_group_changed": "Изменена группа диалога",
|
||||
"audit.action_conversations_marked_spam": "Диалог помечен спамом",
|
||||
"audit.action_conversations_priority_changed": "Изменён приоритет диалога",
|
||||
"audit.action_conversations_released_to_ai": "Диалог возвращён AI",
|
||||
"audit.action_conversations_returned_to_queue": "Диалог возвращён в очередь",
|
||||
"audit.action_conversations_unarchived": "Диалог возвращён из архива",
|
||||
"audit.action_conversations_voice_sent": "Отправлено голосовое в диалог",
|
||||
"audit.action_demo_install_requested": "Запрошена установка демо-данных",
|
||||
"audit.action_demo_installed": "Демо-данные установлены",
|
||||
"audit.action_demo_installed_accounts": "Созданы учётные записи демо-сотрудников",
|
||||
"audit.action_demo_remove_requested": "Запрошено удаление демо-данных",
|
||||
"audit.action_demo_removed": "Демо-данные удалены",
|
||||
"audit.action_identity_access_assignment_created": "Назначен доступ сотруднику",
|
||||
"audit.action_identity_access_assignment_revoked": "Отозван доступ сотрудника",
|
||||
"audit.action_identity_access_profile_created": "Создан профиль доступа",
|
||||
"audit.action_identity_access_profile_updated": "Изменён профиль доступа",
|
||||
"audit.action_identity_avatar_deleted": "Удалено фото профиля",
|
||||
"audit.action_identity_avatar_updated": "Изменено фото профиля",
|
||||
"audit.action_identity_employee_blocked": "Сотрудник заблокирован",
|
||||
"audit.action_identity_employee_created": "Добавлен сотрудник",
|
||||
"audit.action_identity_employee_invited": "Отправлено приглашение сотруднику",
|
||||
"audit.action_identity_invitation_accepted": "Принято приглашение в организацию",
|
||||
"audit.action_identity_invitation_revoked": "Отозвано приглашение сотрудника",
|
||||
"audit.action_updates_install_requested": "Запущено обновление установки",
|
||||
"audit.action_identity_employee_groups_changed": "Изменены группы сотрудника",
|
||||
"audit.action_identity_employee_password_reset": "Сброшен пароль сотрудника",
|
||||
"audit.action_identity_employee_privileged_action_denied": "Отказано в привилегированном действии",
|
||||
"audit.action_identity_employee_role_changed": "Изменена роль сотрудника",
|
||||
"audit.action_identity_employee_sessions_terminated": "Завершены сессии сотрудника",
|
||||
"audit.action_identity_employee_unblocked": "Сотрудник разблокирован",
|
||||
"audit.action_identity_employee_updated": "Изменён сотрудник",
|
||||
"audit.action_identity_group_created": "Создана группа",
|
||||
"audit.action_identity_group_deleted": "Удалена группа",
|
||||
"audit.action_identity_group_updated": "Изменена группа",
|
||||
"audit.action_identity_instance_setup_completed": "Пройден мастер первого запуска",
|
||||
"audit.action_identity_login_failed": "Неудачный вход",
|
||||
"audit.action_identity_login_succeeded": "Вход в систему",
|
||||
"audit.action_identity_login_totp_required": "Запрошен код двухфакторной проверки",
|
||||
"audit.action_identity_logout": "Выход из системы",
|
||||
"audit.action_identity_owner_bootstrapped": "Создан владелец установки",
|
||||
"audit.action_identity_ownership_transferred": "Передано владение организацией",
|
||||
"audit.action_identity_password_reset_completed": "Пароль восстановлен",
|
||||
"audit.action_identity_password_reset_failed": "Неудачный сброс пароля",
|
||||
"audit.action_identity_password_reset_requested": "Запрошен сброс пароля",
|
||||
"audit.action_identity_profile_language_changed": "Изменён язык интерфейса",
|
||||
"audit.action_identity_profile_password_changed": "Изменён пароль",
|
||||
"audit.action_identity_profile_sessions_revoked": "Завершены свои сессии",
|
||||
"audit.action_identity_profile_totp_disabled": "Отключена двухфакторная проверка",
|
||||
"audit.action_identity_profile_totp_setup_started": "Начата настройка двухфакторной проверки",
|
||||
"audit.action_identity_profile_updated": "Изменён профиль сотрудника",
|
||||
"audit.action_identity_temporary_password_changed": "Сменён временный пароль",
|
||||
"audit.action_identity_totp_enabled": "Включена двухфакторная проверка",
|
||||
"audit.action_identity_totp_setup_failed": "Ошибка настройки двухфакторной проверки",
|
||||
"audit.action_identity_totp_verify_failed": "Неверный код двухфакторной проверки",
|
||||
"audit.action_integrations_integration_created": "Добавлена интеграция",
|
||||
"audit.action_integrations_integration_deleted": "Удалена интеграция",
|
||||
"audit.action_integrations_integration_updated": "Изменена интеграция",
|
||||
"audit.action_organization_owner_activated": "Активирован владелец организации",
|
||||
"audit.action_organization_owner_invitation_requested": "Отправлено приглашение владельцу",
|
||||
"audit.action_organization_provisioned": "Создана организация",
|
||||
"audit.action_support_portal_articles_imported": "Импортированы статьи портала",
|
||||
"audit.action_support_portal_created": "Создан портал",
|
||||
"audit.action_support_portal_draft": "Портал снят с публикации",
|
||||
"audit.action_support_portal_published": "Портал опубликован",
|
||||
"audit.category_access_assignment": "Доступ и сотрудники",
|
||||
"audit.category_administration": "Настройки",
|
||||
"audit.category_ai": "AI и знания",
|
||||
"audit.category_calls": "Звонки",
|
||||
"audit.category_channels": "Каналы",
|
||||
"audit.category_contacts": "Контакты",
|
||||
"audit.category_conversation": "Диалоги",
|
||||
"audit.category_conversations": "Диалоги",
|
||||
"audit.category_demo": "Демо-данные",
|
||||
"audit.category_identity": "Доступ и сотрудники",
|
||||
"audit.category_integrations": "Интеграции",
|
||||
"audit.category_organization": "Организация",
|
||||
"audit.category_support_portal": "Порталы",
|
||||
"audit.object_accessassignment": "Назначение доступа",
|
||||
"audit.object_accessprofile": "Профиль доступа",
|
||||
"audit.object_agentcard": "Агент",
|
||||
"audit.object_channel": "Канал",
|
||||
"audit.object_contact": "Контакт",
|
||||
"audit.object_conversation": "Диалог",
|
||||
"audit.object_employeegroup": "Группа",
|
||||
"audit.object_humanuser": "Сотрудник",
|
||||
"audit.object_instancesettings": "Установка",
|
||||
"audit.object_integration": "Интеграция",
|
||||
"audit.object_knowledge": "Знание",
|
||||
"audit.object_knowledgecategory": "Категория знаний",
|
||||
"audit.object_organization": "Организация",
|
||||
"audit.object_storagesettings": "Хранилище",
|
||||
"audit.object_supportportal": "Портал",
|
||||
"audit.result_denied": "Отклонено",
|
||||
"audit.result_failed": "Ошибка",
|
||||
"audit.result_success": "Выполнено",
|
||||
"audit.category_other": "Прочее",
|
||||
"audit.result_unknown": "Неизвестно",
|
||||
"ai.agent_code_collision": "Не удалось подобрать уникальный код агента",
|
||||
"ai.pick_provider_first": "Для запуска AI выберите провайдера организации",
|
||||
"calls.already_ended": "Звонок уже завершён",
|
||||
"calls.cannot_cancel": "Звонок уже нельзя отменить",
|
||||
"calls.conversation_outside_groups": "Диалог вне групп сотрудника",
|
||||
"calls.conversation_taken": "Диалог уже ведёт другой оператор",
|
||||
"calls.identity_missing_or_ambiguous": "Клиентская identity звонка отсутствует или неоднозначна",
|
||||
"calls.invite_cannot_accept": "Приглашение уже нельзя принять",
|
||||
"calls.invite_cannot_decline": "Приглашение уже нельзя отклонить",
|
||||
"calls.no_access": "Нет доступа к звонкам",
|
||||
"calls.no_client_identity": "У диалога нет клиентской identity для звонка",
|
||||
"calls.not_a_participant": "Сотрудник не является участником звонка",
|
||||
"calls.only_in_open_conversation": "Звонок можно запросить только в открытом диалоге",
|
||||
"calls.operator_busy": "Сотрудник уже участвует в другом звонке",
|
||||
"calls.operator_context_required": "Для звонка требуется контекст сотрудника",
|
||||
"calls.second_call_rejected": "Не удалось создать второй незавершённый звонок",
|
||||
"calls.transition_forbidden": "Переход {current} -> {target} запрещён",
|
||||
"calls.unfinished_call_exists": "В диалоге уже есть незавершённый звонок",
|
||||
"channels.no_active_agent": "У канала нет активного AI-агента",
|
||||
"conversations.unavailable": "Диалог недоступен",
|
||||
"admin.action_not_allowed_for_employee": "Это действие над этим сотрудником недоступно",
|
||||
"admin.email_required": "Укажите email",
|
||||
"admin.email_taken": "Этот email уже используется",
|
||||
"admin.full_name_required": "Укажите имя и фамилию",
|
||||
"admin.invalid_role": "Неизвестная роль",
|
||||
"admin.not_allowed": "Недостаточно прав",
|
||||
"admin.previous_owner_role": "Прежний владелец становится администратором или сотрудником",
|
||||
"admin.target_must_be_active": "Владение передаётся действующему сотруднику",
|
||||
"admin.temporary_passwords_unsupported": "Временные пароли не поддерживаются",
|
||||
"admin.unknown_password_mode": "Неизвестный режим выдачи пароля",
|
||||
"ai.attachment_not_found": "Вложение не найдено",
|
||||
"ai.category_not_found": "Категория не найдена",
|
||||
"ai.documents_non_empty": "documents — непустой список",
|
||||
"ai.file_required": "Нужен файл в поле file (multipart/form-data)",
|
||||
"ai.group_id_integer_or_null": "groupId — целое число или null",
|
||||
"ai.group_id_or_none": "group — идентификатор или none",
|
||||
"ai.history_must_be_list": "history — список",
|
||||
"ai.integration_id_integer": "integrationId — целое число",
|
||||
"ai.knowledge_not_found": "Знание не найдено",
|
||||
"ai.title_required": "Укажите заголовок",
|
||||
"conversations.archived_boolean": "archived — булево значение",
|
||||
"conversations.label_ids_list": "labelIds — список идентификаторов",
|
||||
"identity.invalid_credentials": "Неверный email или пароль",
|
||||
"identity.invalid_totp_code": "Неверный код",
|
||||
"identity.invitation_email_mismatch": "Приглашение выписано на другой адрес",
|
||||
"identity.invitation_invalid": "Приглашение недействительно или истекло",
|
||||
"identity.invitation_account_exists": "Учётная запись с этим адресом уже есть — войдите под ней",
|
||||
"updates.nothing_to_install": "Установлена последняя версия",
|
||||
"updates.updater_offline": "Сервис обновления не запущен — обновление из интерфейса недоступно",
|
||||
"updates.install_in_progress": "Обновление уже идёт",
|
||||
"identity.role_conflict": "У пользователя уже другая роль в этой организации",
|
||||
"identity.token_required": "Нужен токен",
|
||||
"identity.totp_challenge_inactive": "Проверка кода уже неактуальна — войдите заново",
|
||||
"notifications.ids_list_or_all": "ids — список, либо all=true",
|
||||
"notifications.push_types_list": "pushTypes — список",
|
||||
"platform.idempotency_key_required": "Нужен заголовок Idempotency-Key",
|
||||
"portals.articles_non_empty": "articles — непустой список",
|
||||
"profile.current_password_invalid": "Текущий пароль неверен",
|
||||
"profile.totp_enabled_already": "Двухфакторная аутентификация уже включена",
|
||||
"profile.totp_required_by_policy": "Организация требует двухфакторную аутентификацию",
|
||||
"ai.provider_error": "Ошибка провайдера: {error}",
|
||||
"conversations.activity_closed": "Диалог закрыт · {channel}",
|
||||
"conversations.activity_started": "Диалог · {channel}",
|
||||
"conversations.field_too_long": "Поле {field}: не длиннее {limit} символов",
|
||||
"ai.provider_no_transcription": "Провайдер {provider} не поддерживает расшифровку аудио",
|
||||
"ai.transcription_failed": "Расшифровка не удалась: {error}",
|
||||
"ai.transcription_failed_http": "Расшифровка не удалась: HTTP {code} {detail}",
|
||||
"api.expected_integer": "{name}: ожидается целое число",
|
||||
"api.expected_positive": "{name}: ожидается число больше нуля",
|
||||
"api.expected_record_id": "{name}: ожидается идентификатор записи",
|
||||
"settings.storage_unavailable": "Хранилище недоступно: {error}",
|
||||
"admin.actor_system": "Система",
|
||||
"admin.image_formats": "Поддерживаются PNG, JPEG, WebP и SVG",
|
||||
"admin.svg_logo_unsafe": "В SVG не должно быть скриптов, обработчиков событий и внешних ссылок",
|
||||
"admin.logo_too_large": "Размер логотипа не должен превышать 2 МБ",
|
||||
"calls.calls_off_entry_point": "Звонки отключены для этой точки входа",
|
||||
"calls.failure_code_required": "Для FAILED требуется нормализованный failure_code",
|
||||
"calls.video_off_entry_point": "Видеозвонки отключены для этой точки входа",
|
||||
"channels.archived_channel_binding": "Нельзя привязать подключение к архивному каналу",
|
||||
"channels.connection_not_bound": "Подключение не привязано к каналу",
|
||||
"channels.connection_not_found": "Подключение не найдено",
|
||||
"channels.llm_is_not_a_connection": "LLM-провайдер не является подключением канала",
|
||||
"channels.name_required": "Название канала не может быть пустым",
|
||||
"channels.name_too_long": "Название канала длиннее 255 символов",
|
||||
"portals.a_record_mismatch": "A-запись домена указывает не на сервер Chatballs",
|
||||
"portals.a_record_missing": "A-запись домена пока не найдена",
|
||||
"portals.anonymous_widget_missing": "Активный анонимный Web-виджет поддержки не найден",
|
||||
"portals.archived_article_publish": "Архивную статью нельзя публиковать",
|
||||
"portals.archived_article_readonly": "Архивную статью нельзя изменять",
|
||||
"portals.domain_empty_check": "Нельзя подтвердить пустой домен",
|
||||
"portals.domain_is_internal": "Домен внутреннего приложения использовать нельзя",
|
||||
"portals.domain_required_first": "Сначала укажите домен",
|
||||
"portals.domain_same_as_installation": "Свой домен должен отличаться от адреса Chatballs",
|
||||
"portals.domain_taken": "Этот домен уже используется другим порталом",
|
||||
"portals.file_name_required": "Имя файла обязательно",
|
||||
"portals.file_too_large": "Файл больше 25 МБ",
|
||||
"portals.invalid_theme_id": "Некорректный идентификатор темы",
|
||||
"portals.invalid_theme_scheme": "Неизвестная цветовая схема темы",
|
||||
"portals.invalid_theme_setting_name": "Некорректное имя параметра темы",
|
||||
"portals.move_articles_first": "Сначала переместите статьи из этого раздела",
|
||||
"portals.move_nested_sections_first": "Сначала переместите или удалите вложенные разделы",
|
||||
"portals.other_organization": "Портал принадлежит другой организации",
|
||||
"portals.restore_to_edit_content": "Восстановите портал, чтобы изменить его содержимое",
|
||||
"portals.restore_to_edit_domain": "Восстановите портал, чтобы изменить его домен",
|
||||
"portals.restore_to_edit_settings": "Восстановите портал, чтобы изменить настройки",
|
||||
"portals.restore_to_verify_domain": "Восстановите портал, чтобы подтвердить его домен",
|
||||
"portals.revision_not_found": "Версия статьи не найдена",
|
||||
"portals.slug_taken": "Этот адрес уже используется другим порталом",
|
||||
"portals.theme_settings_json": "Параметры темы должны быть JSON-объектом",
|
||||
"portals.theme_settings_object": "Параметры темы должны быть объектом",
|
||||
"portals.theme_settings_too_large": "Параметры темы слишком велики",
|
||||
"portals.unknown_status": "Неизвестный статус портала",
|
||||
"portals.widget_channel_missing": "Канал веб-виджета не найден",
|
||||
"profile.photo_too_large": "Размер фото не должен превышать 2 МБ",
|
||||
"settings.local_or_s3": "LOCAL или S3",
|
||||
"settings.object_written_not_readable": "Объект записан, но не читается — проверьте права на чтение",
|
||||
"settings.seconds_number": "Число секунд",
|
||||
"admin.group_name_required": "Укажите название группы",
|
||||
"ai.agent_other_organization": "Агент принадлежит другой организации",
|
||||
"ai.archived_agent_state": "Состояние архивного агента не меняется",
|
||||
"ai.at_least_one_knowledge_id": "Нужен хотя бы один идентификатор знания",
|
||||
"ai.attachment_other_organization": "Вложение принадлежит другой организации",
|
||||
"ai.category_cycle": "Категорию нельзя перенести внутрь себя",
|
||||
"ai.category_name_required": "Укажите название категории",
|
||||
"ai.category_names_strings": "Названия категорий — непустые строки",
|
||||
"ai.category_other_organization": "Категория принадлежит другой организации",
|
||||
"ai.category_path_not_found": "Путь категории не найден",
|
||||
"ai.category_path_required": "«categoryPath» — непустой список",
|
||||
"ai.category_with_children": "Сначала перенесите вложенные категории",
|
||||
"ai.category_with_knowledge": "Сначала перенесите знания из категории",
|
||||
"ai.channel_required": "Укажите канал",
|
||||
"ai.content_required": "Поле «content» обязательно",
|
||||
"ai.description_string": "Поле «description» — строка",
|
||||
"ai.document_object_required": "Документ должен быть объектом",
|
||||
"ai.expected_attach_or_detach": "Ожидается attach или detach",
|
||||
"ai.file_name_required": "Имя файла обязательно",
|
||||
"ai.file_too_large_25": "Файл больше 25 МБ",
|
||||
"ai.integration_model_required": "У интеграции не задана модель по умолчанию",
|
||||
"ai.knowledge_ids_integers": "Идентификаторы знаний — целые числа",
|
||||
"ai.knowledge_other_organization": "Знание принадлежит другой организации",
|
||||
"ai.knowledge_unknown_or_unavailable": "Знание неизвестно или недоступно",
|
||||
"ai.list_of_ids": "Нужен список идентификаторов",
|
||||
"ai.list_of_knowledge_ids": "Нужен список идентификаторов знаний",
|
||||
"ai.parent_category_not_found": "Родительская категория не найдена",
|
||||
"ai.parent_category_other_organization": "Родительская категория принадлежит другой организации",
|
||||
"ai.system_category_immutable": "Системная категория не изменяется",
|
||||
"ai.system_category_name_immutable": "Название системной категории не меняется",
|
||||
"ai.system_category_undeletable": "Системную категорию нельзя удалить",
|
||||
"ai.unknown_knowledge_item": "Неизвестное знание",
|
||||
"ai.unknown_portal_article": "Неизвестная статья портала",
|
||||
"ai.unknown_provider_integration": "Неизвестная интеграция OpenRouter или Custom",
|
||||
"api.at_least_one_id": "Нужен хотя бы один идентификатор",
|
||||
"api.boolean_required": "Нужно булево значение",
|
||||
"api.integer_id_required": "Нужен целочисленный идентификатор",
|
||||
"api.integer_required": "Нужно целое число",
|
||||
"api.list_of_ids_required": "Нужен список идентификаторов",
|
||||
"api.object_required": "Нужен объект",
|
||||
"api.positive_id_required": "Нужен положительный целочисленный идентификатор",
|
||||
"channels.agent_exists": "У канала уже есть AI-агент",
|
||||
"channels.not_found": "Канал не найден",
|
||||
"channels.unknown_group": "Неизвестная группа",
|
||||
"conversations.file_download_unsupported": "Этот канал не поддерживает скачивание файлов",
|
||||
"conversations.telegram_getfile_failed": "Telegram не отдал файл",
|
||||
"conversations.voice_download_unsupported": "Этот канал не поддерживает скачивание голосовых",
|
||||
"portals.article_object_required": "Статья должна быть объектом",
|
||||
"portals.locale_string": "Поле «locale» — строка",
|
||||
"portals.summary_string": "Поле «summary» — строка",
|
||||
"portals.widget_channel_mismatch": "Виджет и канал не совпадают",
|
||||
"portals.widget_needs_anonymous": "Виджет портала должен допускать анонимные сессии",
|
||||
"portals.widget_needs_channel": "Виджет портала должен быть привязан к каналу",
|
||||
"settings.allowed_origins_list": "allowedOrigins — список строк",
|
||||
"settings.api_key_required": "Укажите API-ключ",
|
||||
"settings.custom_base_url_required": "Укажите Base URL",
|
||||
"settings.custom_model_required": "Укажите модель",
|
||||
"settings.email_hosts_required": "Укажите адрес, IMAP- и SMTP-сервер",
|
||||
"settings.email_not_a_bot": "Почта не может быть ботом уведомлений",
|
||||
"settings.inactive_channel": "Неактивный канал не принимает подключения",
|
||||
"settings.integration_other_organization": "Интеграция принадлежит другой организации",
|
||||
"settings.mailbox_password_required": "Укажите пароль ящика",
|
||||
"settings.name_required": "Укажите название",
|
||||
"settings.ports_numbers": "Порты — числа",
|
||||
"settings.quick_replies_list": "quickReplies — список строк",
|
||||
"settings.unknown_provider": "Неизвестный провайдер",
|
||||
"ai.field_required": "Поле «{field}» обязательно",
|
||||
"ai.unexpected_provider_response": "Неожиданный ответ провайдера: {error}",
|
||||
"settings.base_url_rejected": "Base URL: {error}",
|
||||
"settings.proxy_url_rejected": "Прокси: {error}",
|
||||
"notifications.type_dialog_waiting": "Диалог ждёт оператора",
|
||||
"notifications.type_dialog_new_message": "Новое сообщение в диалоге",
|
||||
"notifications.type_release_published": "Опубликован релиз агента",
|
||||
"notifications.type_integration_error": "Ошибка интеграции",
|
||||
"integrations.check_no_connection": "Нет связи: {error}",
|
||||
"integrations.check_api_key_missing": "Не указан API-ключ",
|
||||
"integrations.check_base_url_missing": "Не указан Base URL",
|
||||
"integrations.check_bot_token_missing": "Не указан токен бота",
|
||||
"integrations.check_mailbox_password_missing": "Не указан пароль ящика",
|
||||
"integrations.check_mail_hosts_missing": "Не заполнены адрес, IMAP- или SMTP-хост",
|
||||
"integrations.check_provider_answered": "{provider} ответил {status}",
|
||||
"integrations.check_endpoint_answered": "Эндпоинт ответил {status}",
|
||||
"integrations.check_key_accepted": "ключ принят",
|
||||
"integrations.check_bot_connected": "бот подключён",
|
||||
"integrations.check_demo": "Демо-провайдер: отвечает по знаниям агента, без внешних запросов и ключей",
|
||||
"integrations.check_unsupported": "Проверка для этого типа подключения не поддерживается",
|
||||
"integrations.check_web_not_bound": "Подключение не привязано к каналу — виджет не активен",
|
||||
"integrations.check_web_no_config": "Конфигурация Web-виджета не создана",
|
||||
"integrations.check_web_no_origins": "Не заданы разрешённые домены — виджет будет недоступен на сайте",
|
||||
"integrations.check_web_active": "Web-виджет активен · канал «{channel}»",
|
||||
"integrations.check_endpoint_models": {"one": "Эндпоинт отвечает: {count} модель", "few": "Эндпоинт отвечает: {count} модели", "many": "Эндпоинт отвечает: {count} моделей", "other": "Эндпоинт отвечает: {count} модели"},
|
||||
"identity.password_help": "Пароль должен содержать букву, цифру и спецсимвол.",
|
||||
"conversations.attachment_too_large": "Файл больше 20 МБ",
|
||||
"conversations.attachment_empty": "Пустой файл",
|
||||
"conversations.attachment_type_blocked": "Такой тип файла отправить нельзя",
|
||||
"settings.s3_bucket_required": "Укажите имя бакета",
|
||||
"settings.s3_endpoint_scheme": "Адрес должен начинаться с http:// или https://",
|
||||
"settings.s3_access_key_required": "Укажите Access Key",
|
||||
"settings.s3_secret_key_required": "Укажите Secret Key",
|
||||
"channels.binding_conflict": "Подключение уже привязано к другому каналу: перенос требует force",
|
||||
"channels.operation_needs_manage": "{operation} требует channels.manage",
|
||||
"channels.operation_status_change": "Изменение статуса канала",
|
||||
"channels.operation_policy_change": "Изменение политики канала",
|
||||
"conversations.dialog_status_closed": "Закрыт",
|
||||
"conversations.dialog_status_operator": "Оператор",
|
||||
"conversations.dialog_status_wait": "Ждёт оператора",
|
||||
"setup.your_name_required": "Укажите ваше имя",
|
||||
"setup.your_name_too_long": "Имя длиннее 255 символов",
|
||||
"setup.email_invalid": "Укажите корректный e-mail",
|
||||
"setup.check_fields": "Проверьте заполненные поля",
|
||||
"setup.owner_position": "Владелец",
|
||||
"ai.agent_without_llm_integration": "Агент не привязан к LLM-интеграции BYOK; выберите провайдера в настройках агента",
|
||||
"ai.integration_is_not_llm": "Интеграция «{provider}» не является LLM-провайдером BYOK",
|
||||
"settings.url_scheme_required": "Адрес должен начинаться с одной из схем: {schemes}",
|
||||
"settings.download_http_only": "Скачивать можно только по http/https",
|
||||
"settings.url_points_inside": "Адрес ведёт во внутреннюю сеть",
|
||||
"settings.scheme_forbidden": "Схема {scheme} в исходящих запросах запрещена",
|
||||
"conversations.contact_request": "Поделитесь, пожалуйста, контактом — нажмите кнопку ниже.",
|
||||
"conversations.contact_request_web": "Поделитесь, пожалуйста, номером телефона.",
|
||||
"calls.invite_text": "Приглашаем вас на {kind}. Нажмите кнопку, чтобы перейти к звонку.",
|
||||
"conversations.email_default_subject": "Ваше обращение",
|
||||
"conversations.voice_message": "Голосовое сообщение",
|
||||
"conversations.file_caption": "Файл: {name}",
|
||||
"webchat.default_greeting": "Здравствуйте! Готов помочь и ответить на вопросы. Чем можем помочь?",
|
||||
"webchat.default_consent": "Продолжая, вы соглашаетесь на обработку сообщений для ответа на обращение.",
|
||||
"webchat.guest_name": "Гость · {code}",
|
||||
"channels.operation_agent_create": "Создание агента",
|
||||
"channels.operation_agent_delete": "Удаление агента",
|
||||
"identity.browser_unknown": "Браузер",
|
||||
"settings.mail_test_subject": "Проверка почты Chatballs",
|
||||
"settings.mail_test_body": "Это тестовое письмо из «Настроек». Если вы его читаете, почта установки работает.",
|
||||
"notifications.binding_done": "Готово! Уведомления Chatballs подключены. Отключить можно в профиле.",
|
||||
"notifications.binding_hint": "Это сервисный бот уведомлений Chatballs. Чтобы подключить уведомления, откройте профиль в Chatballs и нажмите «Подключить».",
|
||||
"conversations.button_share_contact": "Поделиться контактом",
|
||||
"conversations.button_join_call": "Перейти к звонку",
|
||||
"ai.demo_handover": "Уточню этот вопрос у коллег и передам диалог сотруднику — он ответит в рабочее время.",
|
||||
"ai.demo_handover_suffix": " Передаю диалог сотруднику — он поможет дальше.",
|
||||
"ai.unknown_answer_language": "Неизвестный язык ответов агента",
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
"""Активация языка на время одного запроса.
|
||||
|
||||
Стоит последней в цепочке — ближе всех к вьюхе, — потому что организацию
|
||||
запроса определяет ``TenantContextMiddleware``, и до неё язык организации ещё
|
||||
неизвестен. Активируется штатный механизм Django: от него зависят и наш
|
||||
каталог, и сообщения DRF о невалидных полях.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
|
||||
from django.http import HttpRequest, HttpResponse
|
||||
from django.utils import translation
|
||||
|
||||
from chatballs.i18n.languages import INHERIT, first_chosen, resolve_language
|
||||
|
||||
|
||||
class LanguageMiddleware:
|
||||
def __init__(self, get_response: Callable[[HttpRequest], HttpResponse]) -> None:
|
||||
self.get_response = get_response
|
||||
|
||||
def __call__(self, request: HttpRequest) -> HttpResponse:
|
||||
# Язык установки спрашивается последним и только если до него дошло:
|
||||
# это чтение из базы, и запросу сотрудника со своим языком (или из
|
||||
# организации со своим) оно не нужно вовсе.
|
||||
language = first_chosen(
|
||||
self._user_language(request), self._organization_language(request)
|
||||
) or resolve_language(
|
||||
instance_language=self._instance_language(),
|
||||
accept_language=request.headers.get("Accept-Language"),
|
||||
)
|
||||
with translation.override(language):
|
||||
request.LANGUAGE_CODE = language
|
||||
response = self.get_response(request)
|
||||
# Заголовок нужен кэшам и прокси: один и тот же URL отдаёт разный текст
|
||||
# для разных людей, и без Vary ответ одного уедет другому.
|
||||
response.setdefault("Content-Language", language)
|
||||
existing = response.get("Vary", "")
|
||||
if "accept-language" not in existing.lower():
|
||||
response["Vary"] = f"{existing}, Accept-Language".lstrip(", ")
|
||||
return response
|
||||
|
||||
@staticmethod
|
||||
def _user_language(request: HttpRequest) -> str:
|
||||
user = getattr(request, "user", None)
|
||||
if user is None or not user.is_authenticated:
|
||||
return INHERIT
|
||||
return getattr(user, "ui_language", INHERIT) or INHERIT
|
||||
|
||||
@staticmethod
|
||||
def _organization_language(request: HttpRequest) -> str:
|
||||
context = getattr(request, "tenant_context", None)
|
||||
if context is None:
|
||||
return INHERIT
|
||||
return getattr(context.organization, "language", INHERIT) or INHERIT
|
||||
|
||||
@staticmethod
|
||||
def _instance_language() -> str:
|
||||
from chatballs.identity.instance_settings import default_language
|
||||
|
||||
return default_language()
|
||||
@@ -0,0 +1,161 @@
|
||||
"""Проверки языка интерфейса.
|
||||
|
||||
Главная из них — совпадение наборов ключей в каталогах. Пропущенный перевод не
|
||||
роняет запрос (строка выходит по-русски), и именно поэтому его легко не
|
||||
заметить: без теста английский каталог отстаёт молча.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pathlib
|
||||
import re
|
||||
|
||||
from django.test import SimpleTestCase, TestCase, override_settings
|
||||
|
||||
from chatballs.i18n import normalize_language, parse_accept_language, resolve_language, t
|
||||
from chatballs.i18n.catalog import _plural_category
|
||||
from chatballs.i18n.languages import DEFAULT_LANGUAGE, LANGUAGE_CODES
|
||||
from chatballs.i18n.messages import CATALOG
|
||||
|
||||
|
||||
class CatalogTests(SimpleTestCase):
|
||||
def test_every_language_has_the_same_keys(self) -> None:
|
||||
russian = set(CATALOG["ru"])
|
||||
for code, table in CATALOG.items():
|
||||
if code == DEFAULT_LANGUAGE:
|
||||
continue
|
||||
self.assertEqual(
|
||||
russian - set(table), set(), f"нет перевода на {code}"
|
||||
)
|
||||
self.assertEqual(
|
||||
set(table) - russian, set(), f"лишние ключи в {code}"
|
||||
)
|
||||
|
||||
def test_placeholders_match_between_languages(self) -> None:
|
||||
"""Параметры фразы обязаны совпадать: иначе подстановка молча потеряется."""
|
||||
|
||||
placeholders = re.compile(r"\{(\w+)\}")
|
||||
|
||||
def names(message: object) -> set[str]:
|
||||
if isinstance(message, dict):
|
||||
return {n for form in message.values() for n in placeholders.findall(form)}
|
||||
return set(placeholders.findall(str(message)))
|
||||
|
||||
for key, russian in CATALOG["ru"].items():
|
||||
for code, table in CATALOG.items():
|
||||
if code == DEFAULT_LANGUAGE:
|
||||
continue
|
||||
self.assertEqual(
|
||||
names(russian), names(table[key]), f"{key}: расходятся параметры в {code}"
|
||||
)
|
||||
|
||||
def test_every_key_used_in_code_exists(self) -> None:
|
||||
"""Вызов t() без ключа в каталоге молча печатает сам ключ.
|
||||
|
||||
Так уже случалось: строки заменили на вызовы, а ключи в каталог
|
||||
дописать забыли — и вместо текста в ответе поехало «channels.
|
||||
connection_not_found». Ошибка не падает и в логах не видна, поэтому
|
||||
ищется здесь, обходом исходников.
|
||||
"""
|
||||
|
||||
import ast
|
||||
|
||||
root = pathlib.Path(__file__).resolve().parents[1]
|
||||
known = set(CATALOG[DEFAULT_LANGUAGE])
|
||||
unknown: list[str] = []
|
||||
for source in root.rglob("*.py"):
|
||||
# Каталог — это сами словари; тесты намеренно зовут несуществующий
|
||||
# ключ, проверяя фолбэк.
|
||||
if "__pycache__" in source.parts or source.parts[-2:-1] == ("messages",):
|
||||
continue
|
||||
if "test" in source.name:
|
||||
continue
|
||||
for node in ast.walk(ast.parse(source.read_text(encoding="utf-8"))):
|
||||
if not isinstance(node, ast.Call):
|
||||
continue
|
||||
if getattr(node.func, "id", "") not in ("t", "tn") or not node.args:
|
||||
continue
|
||||
first = node.args[0]
|
||||
# Ключ, собранный из выражения, проверить статически нельзя —
|
||||
# такие места отвечают за себя сами (см. _system_text).
|
||||
if isinstance(first, ast.Constant) and isinstance(first.value, str):
|
||||
if first.value not in known:
|
||||
unknown.append(f"{source.name}:{node.lineno} {first.value}")
|
||||
self.assertEqual(unknown, [], "ключи вызываются, но в каталоге их нет")
|
||||
|
||||
def test_unknown_key_returns_itself(self) -> None:
|
||||
self.assertEqual(t("нет.такого.ключа"), "нет.такого.ключа")
|
||||
|
||||
def test_plural_rule_follows_the_language(self) -> None:
|
||||
"""Форма выбирается по правилам CLDR, а не по «1 — единственное».
|
||||
|
||||
Строк с числом в каталоге бэкенда сейчас нет, и проверять нечего, кроме
|
||||
самого правила: русскому нужны четыре формы, английскому — две, и
|
||||
ошибка здесь всплыла бы на первой же фразе со счётчиком.
|
||||
"""
|
||||
|
||||
self.assertEqual([_plural_category("ru", n) for n in (1, 2, 5, 11, 21)],
|
||||
["one", "few", "many", "many", "one"])
|
||||
self.assertEqual([_plural_category("en", n) for n in (1, 2)], ["one", "other"])
|
||||
|
||||
|
||||
class LanguageResolutionTests(SimpleTestCase):
|
||||
def test_normalize_accepts_region_and_case(self) -> None:
|
||||
self.assertEqual(normalize_language("ru-RU"), "ru")
|
||||
self.assertEqual(normalize_language("EN"), "en")
|
||||
self.assertEqual(normalize_language("ru_ru"), "ru")
|
||||
|
||||
def test_unknown_language_is_not_a_choice(self) -> None:
|
||||
# Не ошибка, а «выбора нет»: запрос сводится к языку выше по цепочке.
|
||||
self.assertEqual(normalize_language("de"), "")
|
||||
self.assertEqual(normalize_language(None), "")
|
||||
|
||||
def test_accept_language_respects_quality(self) -> None:
|
||||
self.assertEqual(parse_accept_language("en;q=0.9, ru"), "ru")
|
||||
self.assertEqual(parse_accept_language("en-GB,en;q=0.9"), "en")
|
||||
self.assertEqual(parse_accept_language("de,fr;q=0.5"), "")
|
||||
|
||||
def test_personal_choice_beats_organization(self) -> None:
|
||||
self.assertEqual(
|
||||
resolve_language(user_language="en", organization_language="ru"), "en"
|
||||
)
|
||||
|
||||
def test_organization_beats_installation(self) -> None:
|
||||
self.assertEqual(
|
||||
resolve_language(organization_language="en", instance_language="ru"), "en"
|
||||
)
|
||||
|
||||
def test_browser_is_the_last_resort(self) -> None:
|
||||
self.assertEqual(resolve_language(accept_language="en-US"), "en")
|
||||
self.assertEqual(resolve_language(), DEFAULT_LANGUAGE)
|
||||
|
||||
|
||||
class FrontendListTests(SimpleTestCase):
|
||||
"""Список языков продублирован во фронтенде — он обязан совпадать.
|
||||
|
||||
Виджет открывается у клиента до любого запроса к API и знает свой язык сам,
|
||||
поэтому список там свой. Расхождение означало бы язык, который интерфейс
|
||||
предлагает, а сервер не принимает.
|
||||
"""
|
||||
|
||||
def test_frontend_language_list_matches(self) -> None:
|
||||
source = (
|
||||
pathlib.Path(__file__).resolve().parents[4]
|
||||
/ "packages/shared/src/i18n/index.ts"
|
||||
)
|
||||
if not source.exists():
|
||||
# В образе бэкенда фронтенда нет: проверка имеет смысл только на
|
||||
# полном чекауте, где и правят оба списка.
|
||||
self.skipTest("фронтенд не смонтирован")
|
||||
codes = re.findall(r'\{ code: "(\w+)", label:', source.read_text(encoding="utf-8"))
|
||||
self.assertEqual(tuple(codes), LANGUAGE_CODES)
|
||||
|
||||
|
||||
@override_settings(ROOT_URLCONF="chatballs_backend.urls")
|
||||
class MiddlewareTests(TestCase):
|
||||
def test_response_declares_the_language(self) -> None:
|
||||
"""Ответ помечен языком и Vary: один URL отдаёт разный текст."""
|
||||
|
||||
response = self.client.get("/api/v1/auth/session/", HTTP_ACCEPT_LANGUAGE="en")
|
||||
self.assertEqual(response["Content-Language"], "en")
|
||||
self.assertIn("Accept-Language", response["Vary"])
|
||||
@@ -14,12 +14,12 @@ from chatballs.identity.models import (
|
||||
@admin.register(HumanUser)
|
||||
class HumanUserAdmin(UserAdmin):
|
||||
ordering = ["email"]
|
||||
list_display = ["email", "full_name", "is_active", "is_staff", "last_login"]
|
||||
list_display = ["email", "full_name", "is_active", "is_staff", "is_instance_admin", "last_login"]
|
||||
search_fields = ["email", "full_name"]
|
||||
fieldsets = (
|
||||
(None, {"fields": ("email", "password")}),
|
||||
("Personal info", {"fields": ("full_name", "first_name", "last_name")}),
|
||||
("Permissions", {"fields": ("is_active", "is_staff", "is_superuser", "groups", "user_permissions")}),
|
||||
("Permissions", {"fields": ("is_active", "is_staff", "is_superuser", "is_instance_admin", "groups", "user_permissions")}),
|
||||
("Important dates", {"fields": ("last_login", "date_joined")}),
|
||||
)
|
||||
add_fieldsets = (
|
||||
|
||||
@@ -4,12 +4,14 @@ from zoneinfo import available_timezones
|
||||
|
||||
from django.urls import reverse
|
||||
|
||||
from chatballs.i18n import t
|
||||
from chatballs.i18n.languages import LANGUAGES
|
||||
from chatballs.identity.audit_catalog import (
|
||||
AUDIT_RESULT_LABELS,
|
||||
audit_action_label,
|
||||
audit_category,
|
||||
audit_category_label,
|
||||
audit_object_label,
|
||||
audit_result_label,
|
||||
)
|
||||
from chatballs.identity.models import AuditEvent, Organization
|
||||
|
||||
@@ -42,6 +44,9 @@ def organization_settings_payload(organization: Organization) -> dict[str, objec
|
||||
"name": organization.name,
|
||||
"timezone": organization.timezone,
|
||||
"currency": organization.currency,
|
||||
# Пустая строка доезжает до интерфейса как есть: там это отдельный
|
||||
# пункт «Как в установке», а не отсутствие значения.
|
||||
"language": organization.language,
|
||||
"updatedAt": organization_updated_at(organization),
|
||||
"logoUrl": (
|
||||
reverse(
|
||||
@@ -58,6 +63,17 @@ def administration_timezones() -> list[str]:
|
||||
return sorted(available_timezones())
|
||||
|
||||
|
||||
def administration_languages() -> list[dict[str, str]]:
|
||||
"""Языки для выпадающего списка в «Организации».
|
||||
|
||||
Подпись — на самом языке («Русский», «English»), а не переведённая:
|
||||
человек ищет в списке свой язык, и «Русский» он узнает, даже когда
|
||||
интерфейс сейчас английский.
|
||||
"""
|
||||
|
||||
return [{"code": code, "label": label} for code, label in LANGUAGES]
|
||||
|
||||
|
||||
def audit_event_payload(event: AuditEvent) -> dict[str, object]:
|
||||
"""Событие журнала. Отдаём и код действия, и подпись: подписи может не быть
|
||||
(тогда интерфейс показывает код), а код нужен для поиска в любом случае."""
|
||||
@@ -68,7 +84,7 @@ def audit_event_payload(event: AuditEvent) -> dict[str, object]:
|
||||
"id": event.id,
|
||||
"createdAt": event.created_at.isoformat(),
|
||||
"actorId": actor.id if actor is not None else None,
|
||||
"actor": ((actor.full_name or actor.email) if actor is not None else "Система"),
|
||||
"actor": ((actor.full_name or actor.email) if actor is not None else t("admin.actor_system")),
|
||||
"actorEmail": (actor.email if actor is not None else ""),
|
||||
"action": event.action,
|
||||
"actionLabel": audit_action_label(event.action),
|
||||
@@ -78,7 +94,7 @@ def audit_event_payload(event: AuditEvent) -> dict[str, object]:
|
||||
"objectType": event.object_type,
|
||||
"objectId": event.object_id,
|
||||
"result": event.result,
|
||||
"resultLabel": AUDIT_RESULT_LABELS.get(event.result, "Неизвестно"),
|
||||
"resultLabel": audit_result_label(event.result),
|
||||
"sourceIp": event.source_ip or "",
|
||||
"correlationId": event.correlation_id,
|
||||
"details": event.payload or {},
|
||||
|
||||
@@ -9,6 +9,9 @@ from django.core.files.base import ContentFile
|
||||
from django.core.files.uploadedfile import UploadedFile
|
||||
from django.db import transaction
|
||||
|
||||
from chatballs.i18n import t
|
||||
from chatballs.i18n.languages import normalize_language
|
||||
from chatballs.identity.logo_svg import SVG_CONTENT_TYPE, looks_like_svg, svg_is_safe
|
||||
from chatballs.identity.models import Organization
|
||||
from chatballs.tenancy.context import TenantContext
|
||||
from chatballs.tenancy.storage import adjust_storage_usage
|
||||
@@ -26,27 +29,36 @@ class OrganizationSettingsInput:
|
||||
name: str
|
||||
timezone: str
|
||||
currency: str
|
||||
# Пустая строка — «как в установке»: организация не обязана выбирать язык,
|
||||
# и владелец, который его не трогал, не должен получить жёсткий русский
|
||||
# после того, как язык установки сменили.
|
||||
language: str = ""
|
||||
|
||||
|
||||
def _validate_input(data: OrganizationSettingsInput) -> OrganizationSettingsInput:
|
||||
name = data.name.strip()
|
||||
timezone = data.timezone.strip()
|
||||
currency = data.currency.strip().upper()
|
||||
language = normalize_language(data.language)
|
||||
if data.language.strip() and not language:
|
||||
raise ValidationError({"language": t("settings.language_unsupported")})
|
||||
if not name:
|
||||
raise ValidationError({"name": "Укажите название организации"})
|
||||
raise ValidationError({"name": t("admin.organization_name_required")})
|
||||
if len(name) > 255:
|
||||
raise ValidationError({"name": "Название не должно превышать 255 символов"})
|
||||
raise ValidationError({"name": t("admin.name_too_long")})
|
||||
try:
|
||||
ZoneInfo(timezone)
|
||||
except (ValueError, ZoneInfoNotFoundError) as error:
|
||||
raise ValidationError(
|
||||
{"timezone": "Укажите корректный часовой пояс"}
|
||||
{"timezone": t("admin.invalid_timezone")}
|
||||
) from error
|
||||
if currency != "RUB":
|
||||
raise ValidationError(
|
||||
{"currency": "Поддерживается только российский рубль (RUB)"}
|
||||
{"currency": t("admin.currency_rub_only")}
|
||||
)
|
||||
return OrganizationSettingsInput(name=name, timezone=timezone, currency=currency)
|
||||
return OrganizationSettingsInput(
|
||||
name=name, timezone=timezone, currency=currency, language=language
|
||||
)
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
@@ -62,7 +74,8 @@ def update_organization_settings(
|
||||
organization.name = clean.name
|
||||
organization.timezone = clean.timezone
|
||||
organization.currency = clean.currency
|
||||
organization.save(update_fields=["name", "timezone", "currency"])
|
||||
organization.language = clean.language
|
||||
organization.save(update_fields=["name", "timezone", "currency", "language"])
|
||||
return organization
|
||||
|
||||
|
||||
@@ -73,6 +86,8 @@ def _image_type(data: bytes) -> tuple[str, str] | None:
|
||||
return "image/jpeg", ".jpg"
|
||||
if len(data) >= 12 and data[:4] == b"RIFF" and data[8:12] == b"WEBP":
|
||||
return "image/webp", ".webp"
|
||||
if looks_like_svg(data):
|
||||
return SVG_CONTENT_TYPE, ".svg"
|
||||
return None
|
||||
|
||||
|
||||
@@ -84,12 +99,16 @@ def replace_organization_logo(
|
||||
) -> Organization:
|
||||
data = upload.read()
|
||||
if not data:
|
||||
raise ValidationError({"file": "Выберите файл логотипа"})
|
||||
raise ValidationError({"file": t("admin.choose_logo_file")})
|
||||
if len(data) > MAX_LOGO_BYTES:
|
||||
raise ValidationError({"file": "Размер логотипа не должен превышать 2 МБ"})
|
||||
raise ValidationError({"file": t("admin.logo_too_large")})
|
||||
detected = _image_type(data)
|
||||
if detected is None:
|
||||
raise ValidationError({"file": "Поддерживаются PNG, JPEG и WebP"})
|
||||
raise ValidationError({"file": t("admin.image_formats")})
|
||||
if detected[0] == SVG_CONTENT_TYPE and not svg_is_safe(data):
|
||||
# Скрипты, внешние ссылки и обработчики событий в логотипе не нужны:
|
||||
# файл отклоняется целиком, а не переписывается молча.
|
||||
raise ValidationError({"file": t("admin.svg_logo_unsafe")})
|
||||
content_type, suffix = detected
|
||||
organization = Organization.objects.select_for_update().get(
|
||||
pk=context.organization_id
|
||||
|
||||
@@ -13,7 +13,9 @@ from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
|
||||
from chatballs.api.permissions import HasCapability
|
||||
from chatballs.i18n import t
|
||||
from chatballs.identity.administration_payloads import (
|
||||
administration_languages,
|
||||
administration_timezones,
|
||||
audit_event_payload,
|
||||
organization_settings_payload,
|
||||
@@ -29,6 +31,7 @@ from chatballs.identity.audit_catalog import (
|
||||
AUDIT_CATEGORY_ALIASES,
|
||||
AUDIT_RESULT_LABELS,
|
||||
audit_categories,
|
||||
audit_result_label,
|
||||
)
|
||||
from chatballs.identity.models import AuditEvent
|
||||
|
||||
@@ -39,7 +42,7 @@ def _validation_response(error: ValidationError) -> Response:
|
||||
key: messages[0] if isinstance(messages, list) else str(messages)
|
||||
for key, messages in error.message_dict.items()
|
||||
}
|
||||
detail = next(iter(payload.values()), "Проверьте заполненные поля")
|
||||
detail = next(iter(payload.values()), t("setup.check_fields"))
|
||||
return Response({"detail": detail, "errors": payload}, status=400)
|
||||
return Response({"detail": "; ".join(error.messages)}, status=400)
|
||||
|
||||
@@ -58,6 +61,7 @@ class OrganizationSettingsView(APIView):
|
||||
request.tenant_context.organization
|
||||
),
|
||||
"timezones": administration_timezones(),
|
||||
"languages": administration_languages(),
|
||||
}
|
||||
)
|
||||
|
||||
@@ -71,6 +75,7 @@ class OrganizationSettingsView(APIView):
|
||||
name=str(body.get("name", organization.name)),
|
||||
timezone=str(body.get("timezone", organization.timezone)),
|
||||
currency=str(body.get("currency", organization.currency)),
|
||||
language=str(body.get("language", organization.language)),
|
||||
),
|
||||
)
|
||||
except ValidationError as error:
|
||||
@@ -101,17 +106,24 @@ class OrganizationLogoView(APIView):
|
||||
def get(self, request: Request):
|
||||
organization = request.tenant_context.organization
|
||||
if not organization.logo:
|
||||
return Response({"detail": "Логотип не загружен"}, status=404)
|
||||
return FileResponse(
|
||||
return Response({"detail": t("admin.logo_not_uploaded")}, status=404)
|
||||
response = FileResponse(
|
||||
organization.logo.open("rb"),
|
||||
content_type=organization.logo_content_type or "application/octet-stream",
|
||||
filename="organization-logo",
|
||||
)
|
||||
# Логотип показывается через <img>, но адрес можно открыть и напрямую.
|
||||
# SVG проверен при загрузке; заголовки — второй рубеж: в контексте
|
||||
# документа ему нельзя ни исполнять скрипты, ни ходить наружу, а
|
||||
# браузеру нельзя угадывать тип.
|
||||
response["Content-Security-Policy"] = "default-src 'none'; style-src 'unsafe-inline'; img-src data:; sandbox"
|
||||
response["X-Content-Type-Options"] = "nosniff"
|
||||
return response
|
||||
|
||||
def post(self, request: Request) -> Response:
|
||||
upload = request.FILES.get("file")
|
||||
if upload is None:
|
||||
return Response({"detail": "Выберите файл логотипа"}, status=400)
|
||||
return Response({"detail": t("admin.choose_logo_file")}, status=400)
|
||||
try:
|
||||
organization = replace_organization_logo(
|
||||
context=request.tenant_context,
|
||||
@@ -244,8 +256,8 @@ class AuditListView(APIView):
|
||||
"filters": {
|
||||
"categories": audit_categories(),
|
||||
"results": [
|
||||
{"value": key, "label": label}
|
||||
for key, label in AUDIT_RESULT_LABELS.items()
|
||||
{"value": key, "label": audit_result_label(key)}
|
||||
for key in AUDIT_RESULT_LABELS
|
||||
],
|
||||
"actors": _audit_actors(base),
|
||||
},
|
||||
@@ -277,7 +289,7 @@ def _audit_actors(base) -> list[dict[str, object]]:
|
||||
]
|
||||
actors.sort(key=lambda item: str(item["label"]).lower())
|
||||
if base.filter(actor__isnull=True).exists():
|
||||
actors.append({"value": "system", "label": "Система"})
|
||||
actors.append({"value": "system", "label": t("admin.actor_system")})
|
||||
return actors
|
||||
|
||||
|
||||
|
||||
@@ -14,22 +14,24 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from chatballs.i18n import t
|
||||
|
||||
# Разделы журнала: по ним фильтр и группировка. Ключ — префикс кода действия
|
||||
# до первой точки.
|
||||
AUDIT_CATEGORY_LABELS: dict[str, str] = {
|
||||
"identity": "Доступ и сотрудники",
|
||||
"access_assignment": "Доступ и сотрудники",
|
||||
"administration": "Настройки",
|
||||
"organization": "Организация",
|
||||
"integrations": "Интеграции",
|
||||
"channels": "Каналы",
|
||||
"ai": "AI и знания",
|
||||
"conversations": "Диалоги",
|
||||
"conversation": "Диалоги",
|
||||
"contacts": "Контакты",
|
||||
"calls": "Звонки",
|
||||
"support_portal": "Порталы",
|
||||
"demo": "Демо-данные",
|
||||
"identity": "audit.category_identity",
|
||||
"access_assignment": "audit.category_access_assignment",
|
||||
"administration": "audit.category_administration",
|
||||
"organization": "audit.category_organization",
|
||||
"integrations": "audit.category_integrations",
|
||||
"channels": "audit.category_channels",
|
||||
"ai": "audit.category_ai",
|
||||
"conversations": "audit.category_conversations",
|
||||
"conversation": "audit.category_conversation",
|
||||
"contacts": "audit.category_contacts",
|
||||
"calls": "audit.category_calls",
|
||||
"support_portal": "audit.category_support_portal",
|
||||
"demo": "audit.category_demo",
|
||||
}
|
||||
|
||||
AUDIT_CATEGORY_ORDER = (
|
||||
@@ -56,141 +58,153 @@ AUDIT_CATEGORY_ALIASES = {
|
||||
|
||||
AUDIT_ACTION_LABELS: dict[str, str] = {
|
||||
# --- Доступ и сотрудники ---
|
||||
"identity.login_succeeded": "Вход в систему",
|
||||
"identity.login_failed": "Неудачный вход",
|
||||
"identity.login_totp_required": "Запрошен код двухфакторной проверки",
|
||||
"identity.logout": "Выход из системы",
|
||||
"identity.password_reset_requested": "Запрошен сброс пароля",
|
||||
"identity.password_reset_completed": "Пароль восстановлен",
|
||||
"identity.password_reset_failed": "Неудачный сброс пароля",
|
||||
"identity.temporary_password_changed": "Сменён временный пароль",
|
||||
"identity.profile_updated": "Изменён профиль сотрудника",
|
||||
"identity.profile_password_changed": "Изменён пароль",
|
||||
"identity.profile_sessions_revoked": "Завершены свои сессии",
|
||||
"identity.profile_totp_setup_started": "Начата настройка двухфакторной проверки",
|
||||
"identity.profile_totp_disabled": "Отключена двухфакторная проверка",
|
||||
"identity.totp_enabled": "Включена двухфакторная проверка",
|
||||
"identity.totp_setup_failed": "Ошибка настройки двухфакторной проверки",
|
||||
"identity.totp_verify_failed": "Неверный код двухфакторной проверки",
|
||||
"identity.avatar_updated": "Изменено фото профиля",
|
||||
"identity.avatar_deleted": "Удалено фото профиля",
|
||||
"identity.employee_created": "Добавлен сотрудник",
|
||||
"identity.employee_updated": "Изменён сотрудник",
|
||||
"identity.employee_blocked": "Сотрудник заблокирован",
|
||||
"identity.employee_unblocked": "Сотрудник разблокирован",
|
||||
"identity.employee_role_changed": "Изменена роль сотрудника",
|
||||
"identity.employee_groups_changed": "Изменены группы сотрудника",
|
||||
"identity.employee_password_reset": "Сброшен пароль сотрудника",
|
||||
"identity.employee_sessions_terminated": "Завершены сессии сотрудника",
|
||||
"identity.employee_privileged_action_denied": "Отказано в привилегированном действии",
|
||||
"identity.group_created": "Создана группа",
|
||||
"identity.group_updated": "Изменена группа",
|
||||
"identity.group_deleted": "Удалена группа",
|
||||
"identity.ownership_transferred": "Передано владение организацией",
|
||||
"identity.owner_bootstrapped": "Создан владелец установки",
|
||||
"identity.instance_setup_completed": "Пройден мастер первого запуска",
|
||||
"identity.access_profile_created": "Создан профиль доступа",
|
||||
"identity.access_profile_updated": "Изменён профиль доступа",
|
||||
"identity.access_assignment_created": "Назначен доступ сотруднику",
|
||||
"identity.access_assignment_revoked": "Отозван доступ сотрудника",
|
||||
"access_assignment.created": "Назначен доступ сотруднику",
|
||||
"identity.login_succeeded": "audit.action_identity_login_succeeded",
|
||||
"identity.login_failed": "audit.action_identity_login_failed",
|
||||
"identity.login_totp_required": "audit.action_identity_login_totp_required",
|
||||
"identity.logout": "audit.action_identity_logout",
|
||||
"identity.password_reset_requested": "audit.action_identity_password_reset_requested",
|
||||
"identity.password_reset_completed": "audit.action_identity_password_reset_completed",
|
||||
"identity.password_reset_failed": "audit.action_identity_password_reset_failed",
|
||||
"identity.temporary_password_changed": "audit.action_identity_temporary_password_changed",
|
||||
"identity.profile_updated": "audit.action_identity_profile_updated",
|
||||
"identity.profile_language_changed": "audit.action_identity_profile_language_changed",
|
||||
"identity.profile_password_changed": "audit.action_identity_profile_password_changed",
|
||||
"identity.profile_sessions_revoked": "audit.action_identity_profile_sessions_revoked",
|
||||
"identity.profile_totp_setup_started": "audit.action_identity_profile_totp_setup_started",
|
||||
"identity.profile_totp_disabled": "audit.action_identity_profile_totp_disabled",
|
||||
"identity.totp_enabled": "audit.action_identity_totp_enabled",
|
||||
"identity.totp_setup_failed": "audit.action_identity_totp_setup_failed",
|
||||
"identity.totp_verify_failed": "audit.action_identity_totp_verify_failed",
|
||||
"identity.avatar_updated": "audit.action_identity_avatar_updated",
|
||||
"identity.avatar_deleted": "audit.action_identity_avatar_deleted",
|
||||
"identity.employee_created": "audit.action_identity_employee_created",
|
||||
"identity.employee_invited": "audit.action_identity_employee_invited",
|
||||
"identity.invitation_accepted": "audit.action_identity_invitation_accepted",
|
||||
"identity.invitation_revoked": "audit.action_identity_invitation_revoked",
|
||||
"updates.install_requested": "audit.action_updates_install_requested",
|
||||
"identity.employee_updated": "audit.action_identity_employee_updated",
|
||||
"identity.employee_blocked": "audit.action_identity_employee_blocked",
|
||||
"identity.employee_unblocked": "audit.action_identity_employee_unblocked",
|
||||
"identity.employee_role_changed": "audit.action_identity_employee_role_changed",
|
||||
"identity.employee_groups_changed": "audit.action_identity_employee_groups_changed",
|
||||
"identity.employee_password_reset": "audit.action_identity_employee_password_reset",
|
||||
"identity.employee_sessions_terminated": "audit.action_identity_employee_sessions_terminated",
|
||||
"identity.employee_privileged_action_denied": "audit.action_identity_employee_privileged_action_denied",
|
||||
"identity.group_created": "audit.action_identity_group_created",
|
||||
"identity.group_updated": "audit.action_identity_group_updated",
|
||||
"identity.group_deleted": "audit.action_identity_group_deleted",
|
||||
"identity.ownership_transferred": "audit.action_identity_ownership_transferred",
|
||||
"identity.owner_bootstrapped": "audit.action_identity_owner_bootstrapped",
|
||||
"identity.instance_setup_completed": "audit.action_identity_instance_setup_completed",
|
||||
"identity.access_profile_created": "audit.action_identity_access_profile_created",
|
||||
"identity.access_profile_updated": "audit.action_identity_access_profile_updated",
|
||||
"identity.access_assignment_created": "audit.action_identity_access_assignment_created",
|
||||
"identity.access_assignment_revoked": "audit.action_identity_access_assignment_revoked",
|
||||
"access_assignment.created": "audit.action_access_assignment_created",
|
||||
# --- Настройки ---
|
||||
"administration.organization_updated": "Изменены данные организации",
|
||||
"administration.logo_updated": "Изменён логотип организации",
|
||||
"administration.logo_deleted": "Удалён логотип организации",
|
||||
"administration.communication_updated": "Изменены голосовые и звонки",
|
||||
"administration.storage_updated": "Изменено хранилище файлов",
|
||||
"administration.storage_migration_requested": "Запущен перенос файлов",
|
||||
"administration.instance_updated": "Изменены настройки платформы",
|
||||
"administration.organization_updated": "audit.action_administration_organization_updated",
|
||||
"administration.logo_updated": "audit.action_administration_logo_updated",
|
||||
"administration.logo_deleted": "audit.action_administration_logo_deleted",
|
||||
"administration.communication_updated": "audit.action_administration_communication_updated",
|
||||
"administration.storage_updated": "audit.action_administration_storage_updated",
|
||||
"administration.storage_migration_requested": "audit.action_administration_storage_migration_requested",
|
||||
"administration.instance_updated": "audit.action_administration_instance_updated",
|
||||
# --- Организация ---
|
||||
"organization.provisioned": "Создана организация",
|
||||
"organization.owner_activated": "Активирован владелец организации",
|
||||
"organization.owner_invitation_requested": "Отправлено приглашение владельцу",
|
||||
"organization.provisioned": "audit.action_organization_provisioned",
|
||||
"organization.owner_activated": "audit.action_organization_owner_activated",
|
||||
"organization.owner_invitation_requested": "audit.action_organization_owner_invitation_requested",
|
||||
# --- Интеграции и каналы ---
|
||||
"integrations.integration_created": "Добавлена интеграция",
|
||||
"integrations.integration_updated": "Изменена интеграция",
|
||||
"integrations.integration_deleted": "Удалена интеграция",
|
||||
"channels.channel_created": "Создан канал",
|
||||
"channels.channel_updated": "Изменён канал",
|
||||
"channels.channel_deleted": "Удалён канал",
|
||||
"channels.connection_bound": "Подключение привязано к агенту",
|
||||
"integrations.integration_created": "audit.action_integrations_integration_created",
|
||||
"integrations.integration_updated": "audit.action_integrations_integration_updated",
|
||||
"integrations.integration_deleted": "audit.action_integrations_integration_deleted",
|
||||
"channels.channel_created": "audit.action_channels_channel_created",
|
||||
"channels.channel_updated": "audit.action_channels_channel_updated",
|
||||
"channels.channel_deleted": "audit.action_channels_channel_deleted",
|
||||
"channels.connection_bound": "audit.action_channels_connection_bound",
|
||||
# --- AI и знания ---
|
||||
"ai.agent_created": "Создан агент",
|
||||
"ai.agent_status_changed": "Изменён статус агента",
|
||||
"ai.agent_category_knowledge_selected": "Выбраны знания категории для агента",
|
||||
"ai.agent_knowledge_attached": "Знания подключены к агенту",
|
||||
"ai.agent_knowledge_detached": "Знания отключены от агента",
|
||||
"ai.agent_portal_articles_attached": "Статьи портала подключены к агенту",
|
||||
"ai.agent_portal_articles_detached": "Статьи портала отключены от агента",
|
||||
"ai.knowledge_created": "Добавлено знание",
|
||||
"ai.knowledge_updated": "Изменено знание",
|
||||
"ai.knowledge_deleted": "Удалено знание",
|
||||
"ai.knowledge_reindexed": "Знание переиндексировано",
|
||||
"ai.knowledge_imported": "Знания импортированы",
|
||||
"ai.knowledge_attachment_added": "Добавлено вложение знания",
|
||||
"ai.knowledge_attachment_deleted": "Удалено вложение знания",
|
||||
"ai.knowledge_category_created": "Создана категория знаний",
|
||||
"ai.knowledge_category_updated": "Изменена категория знаний",
|
||||
"ai.knowledge_category_deleted": "Удалена категория знаний",
|
||||
"ai.agent_created": "audit.action_ai_agent_created",
|
||||
"ai.agent_status_changed": "audit.action_ai_agent_status_changed",
|
||||
"ai.agent_category_knowledge_selected": "audit.action_ai_agent_category_knowledge_selected",
|
||||
"ai.agent_knowledge_attached": "audit.action_ai_agent_knowledge_attached",
|
||||
"ai.agent_knowledge_detached": "audit.action_ai_agent_knowledge_detached",
|
||||
"ai.agent_portal_articles_attached": "audit.action_ai_agent_portal_articles_attached",
|
||||
"ai.agent_portal_articles_detached": "audit.action_ai_agent_portal_articles_detached",
|
||||
"ai.knowledge_created": "audit.action_ai_knowledge_created",
|
||||
"ai.knowledge_updated": "audit.action_ai_knowledge_updated",
|
||||
"ai.knowledge_deleted": "audit.action_ai_knowledge_deleted",
|
||||
"ai.knowledge_reindexed": "audit.action_ai_knowledge_reindexed",
|
||||
"ai.knowledge_imported": "audit.action_ai_knowledge_imported",
|
||||
"ai.knowledge_attachment_added": "audit.action_ai_knowledge_attachment_added",
|
||||
"ai.knowledge_attachment_deleted": "audit.action_ai_knowledge_attachment_deleted",
|
||||
"ai.knowledge_category_created": "audit.action_ai_knowledge_category_created",
|
||||
"ai.knowledge_category_updated": "audit.action_ai_knowledge_category_updated",
|
||||
"ai.knowledge_category_deleted": "audit.action_ai_knowledge_category_deleted",
|
||||
# --- Диалоги ---
|
||||
"conversations.claimed": "Диалог взят в работу",
|
||||
"conversations.released_to_ai": "Диалог возвращён AI",
|
||||
"conversations.returned_to_queue": "Диалог возвращён в очередь",
|
||||
"conversations.assignee_changed": "Изменён ответственный за диалог",
|
||||
"conversations.group_changed": "Изменена группа диалога",
|
||||
"conversations.priority_changed": "Изменён приоритет диалога",
|
||||
"conversations.closed": "Диалог закрыт",
|
||||
"conversations.archived": "Диалог отправлен в архив",
|
||||
"conversations.unarchived": "Диалог возвращён из архива",
|
||||
"conversations.marked_spam": "Диалог помечен спамом",
|
||||
"conversations.contact_requested": "Запрошены контакты клиента",
|
||||
"conversations.contact_updated": "Изменён контакт диалога",
|
||||
"conversation.contact_updated": "Изменён контакт диалога",
|
||||
"conversations.file_sent": "Отправлен файл в диалог",
|
||||
"conversations.voice_sent": "Отправлено голосовое в диалог",
|
||||
"conversations.claimed": "audit.action_conversations_claimed",
|
||||
"conversations.released_to_ai": "audit.action_conversations_released_to_ai",
|
||||
"conversations.returned_to_queue": "audit.action_conversations_returned_to_queue",
|
||||
"conversations.assignee_changed": "audit.action_conversations_assignee_changed",
|
||||
"conversations.group_changed": "audit.action_conversations_group_changed",
|
||||
"conversations.priority_changed": "audit.action_conversations_priority_changed",
|
||||
"conversations.closed": "audit.action_conversations_closed",
|
||||
"conversations.archived": "audit.action_conversations_archived",
|
||||
"conversations.unarchived": "audit.action_conversations_unarchived",
|
||||
"conversations.marked_spam": "audit.action_conversations_marked_spam",
|
||||
"conversations.contact_requested": "audit.action_conversations_contact_requested",
|
||||
"conversations.contact_updated": "audit.action_conversations_contact_updated",
|
||||
"conversation.contact_updated": "audit.action_conversation_contact_updated",
|
||||
"conversations.file_sent": "audit.action_conversations_file_sent",
|
||||
"conversations.voice_sent": "audit.action_conversations_voice_sent",
|
||||
# --- Контакты ---
|
||||
"contacts.merged": "Объединение контактов",
|
||||
"contacts.unmerged": "Разъединение контактов",
|
||||
"contacts.merged": "audit.action_contacts_merged",
|
||||
"contacts.unmerged": "audit.action_contacts_unmerged",
|
||||
# --- Звонки ---
|
||||
"calls.requested": "Запрошен звонок",
|
||||
"calls.cancelled": "Звонок отменён",
|
||||
"calls.requested": "audit.action_calls_requested",
|
||||
"calls.cancelled": "audit.action_calls_cancelled",
|
||||
# --- Порталы ---
|
||||
"support_portal.created": "Создан портал",
|
||||
"support_portal.published": "Портал опубликован",
|
||||
"support_portal.draft": "Портал снят с публикации",
|
||||
"support_portal.articles_imported": "Импортированы статьи портала",
|
||||
"support_portal.created": "audit.action_support_portal_created",
|
||||
"support_portal.published": "audit.action_support_portal_published",
|
||||
"support_portal.draft": "audit.action_support_portal_draft",
|
||||
"support_portal.articles_imported": "audit.action_support_portal_articles_imported",
|
||||
# --- Демо-данные ---
|
||||
"demo.install_requested": "Запрошена установка демо-данных",
|
||||
"demo.installed": "Демо-данные установлены",
|
||||
"demo.installed_accounts": "Созданы учётные записи демо-сотрудников",
|
||||
"demo.remove_requested": "Запрошено удаление демо-данных",
|
||||
"demo.removed": "Демо-данные удалены",
|
||||
"demo.install_requested": "audit.action_demo_install_requested",
|
||||
"demo.installed": "audit.action_demo_installed",
|
||||
"demo.installed_accounts": "audit.action_demo_installed_accounts",
|
||||
"demo.remove_requested": "audit.action_demo_remove_requested",
|
||||
"demo.removed": "audit.action_demo_removed",
|
||||
}
|
||||
|
||||
def audit_result_label(result: str) -> str:
|
||||
"""Подпись результата события или «Неизвестно» для чужого кода."""
|
||||
|
||||
key = AUDIT_RESULT_LABELS.get(result)
|
||||
return t(key) if key else t("audit.result_unknown")
|
||||
|
||||
|
||||
AUDIT_RESULT_LABELS = {
|
||||
"SUCCESS": "Выполнено",
|
||||
"DENIED": "Отклонено",
|
||||
"FAILED": "Ошибка",
|
||||
"SUCCESS": "audit.result_success",
|
||||
"DENIED": "audit.result_denied",
|
||||
"FAILED": "audit.result_failed",
|
||||
}
|
||||
|
||||
# Что за объект тронули: тип из модели в человеческом виде. Пусто — покажем
|
||||
# сам object_type, он и так читается латиницей.
|
||||
AUDIT_OBJECT_TYPE_LABELS = {
|
||||
"Organization": "Организация",
|
||||
"HumanUser": "Сотрудник",
|
||||
"EmployeeGroup": "Группа",
|
||||
"Integration": "Интеграция",
|
||||
"Channel": "Канал",
|
||||
"Knowledge": "Знание",
|
||||
"KnowledgeCategory": "Категория знаний",
|
||||
"AgentCard": "Агент",
|
||||
"Conversation": "Диалог",
|
||||
"Contact": "Контакт",
|
||||
"SupportPortal": "Портал",
|
||||
"AccessProfile": "Профиль доступа",
|
||||
"AccessAssignment": "Назначение доступа",
|
||||
"InstanceSettings": "Установка",
|
||||
"StorageSettings": "Хранилище",
|
||||
"Organization": "audit.object_organization",
|
||||
"HumanUser": "audit.object_humanuser",
|
||||
"EmployeeGroup": "audit.object_employeegroup",
|
||||
"Integration": "audit.object_integration",
|
||||
"Channel": "audit.object_channel",
|
||||
"Knowledge": "audit.object_knowledge",
|
||||
"KnowledgeCategory": "audit.object_knowledgecategory",
|
||||
"AgentCard": "audit.object_agentcard",
|
||||
"Conversation": "audit.object_conversation",
|
||||
"Contact": "audit.object_contact",
|
||||
"SupportPortal": "audit.object_supportportal",
|
||||
"AccessProfile": "audit.object_accessprofile",
|
||||
"AccessAssignment": "audit.object_accessassignment",
|
||||
"InstanceSettings": "audit.object_instancesettings",
|
||||
"StorageSettings": "audit.object_storagesettings",
|
||||
}
|
||||
|
||||
|
||||
@@ -202,7 +216,8 @@ def audit_category(action: str) -> str:
|
||||
|
||||
|
||||
def audit_category_label(category: str) -> str:
|
||||
return AUDIT_CATEGORY_LABELS.get(category, "Прочее")
|
||||
key = AUDIT_CATEGORY_LABELS.get(category)
|
||||
return t(key) if key else t("audit.category_other")
|
||||
|
||||
|
||||
def audit_action_label(action: str) -> str:
|
||||
@@ -213,7 +228,8 @@ def audit_action_label(action: str) -> str:
|
||||
надо дописать сюда.
|
||||
"""
|
||||
|
||||
return AUDIT_ACTION_LABELS.get(action, "")
|
||||
key = AUDIT_ACTION_LABELS.get(action)
|
||||
return t(key) if key else ""
|
||||
|
||||
|
||||
def audit_object_label(object_type: str, object_id: str) -> str:
|
||||
@@ -221,7 +237,8 @@ def audit_object_label(object_type: str, object_id: str) -> str:
|
||||
|
||||
if not object_type:
|
||||
return ""
|
||||
label = AUDIT_OBJECT_TYPE_LABELS.get(object_type, object_type)
|
||||
key = AUDIT_OBJECT_TYPE_LABELS.get(object_type)
|
||||
label = t(key) if key else object_type
|
||||
return f"{label} · {object_id}" if object_id else label
|
||||
|
||||
|
||||
@@ -229,6 +246,6 @@ def audit_categories() -> list[dict[str, str]]:
|
||||
"""Разделы для фильтра — в фиксированном порядке, а не в порядке словаря."""
|
||||
|
||||
return [
|
||||
{"value": key, "label": AUDIT_CATEGORY_LABELS[key]}
|
||||
{"value": key, "label": t(AUDIT_CATEGORY_LABELS[key])}
|
||||
for key in AUDIT_CATEGORY_ORDER
|
||||
]
|
||||
@@ -1,4 +1,8 @@
|
||||
from chatballs.identity.auth.invitations import InvitationAcceptView
|
||||
from chatballs.identity.auth.invitations import (
|
||||
InvitationAcceptView,
|
||||
InvitationPreviewView,
|
||||
InvitationRegisterView,
|
||||
)
|
||||
from chatballs.identity.auth.password_reset import (
|
||||
PasswordResetConfirmView,
|
||||
PasswordResetRequestView,
|
||||
@@ -8,6 +12,7 @@ from chatballs.identity.auth.profile import (
|
||||
ChangeTemporaryPasswordView,
|
||||
ProfileAppearanceView,
|
||||
ProfileAvatarView,
|
||||
ProfileLanguageView,
|
||||
ProfilePasswordView,
|
||||
ProfileRevokeOtherSessionsView,
|
||||
ProfileSessionsView,
|
||||
@@ -28,6 +33,7 @@ __all__ = [
|
||||
"ProfileUpdateView",
|
||||
"ProfileAppearanceView",
|
||||
"ProfileAvatarView",
|
||||
"ProfileLanguageView",
|
||||
"ProfilePasswordView",
|
||||
"ProfileTotpStartView",
|
||||
"ProfileTotpDisableView",
|
||||
@@ -38,4 +44,6 @@ __all__ = [
|
||||
"TotpConfirmView",
|
||||
"TotpVerifyView",
|
||||
"InvitationAcceptView",
|
||||
"InvitationPreviewView",
|
||||
"InvitationRegisterView",
|
||||
]
|
||||
@@ -1,31 +1,48 @@
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ValidationError
|
||||
from rest_framework.request import Request
|
||||
from rest_framework.response import Response
|
||||
|
||||
from chatballs.i18n import current_language, t
|
||||
from chatballs.identity.avatars import own_avatar_url
|
||||
from chatballs.identity.models import HumanUser, Organization, OrganizationMembership
|
||||
from chatballs.identity.models import HumanUser, OrganizationMembership
|
||||
from chatballs.identity.policy import get_effective_access
|
||||
from chatballs.identity.sessions import revoke_user_sessions
|
||||
from chatballs.tenancy.database import tenant_atomic
|
||||
from chatballs.tenancy.ingress import membership_routes_for_user
|
||||
|
||||
|
||||
def validation_response(error: ValidationError) -> Response:
|
||||
"""Ошибки формы полями: мастер первого запуска и регистрация по приглашению."""
|
||||
|
||||
if hasattr(error, "message_dict"):
|
||||
errors = {
|
||||
key: messages[0] if isinstance(messages, list) else str(messages)
|
||||
for key, messages in error.message_dict.items()
|
||||
}
|
||||
# validate_password кладёт сообщения без ключа поля.
|
||||
if "__all__" in errors:
|
||||
errors["password"] = " ".join(error.message_dict["__all__"])
|
||||
del errors["__all__"]
|
||||
detail = next(iter(errors.values()), t("setup.check_fields"))
|
||||
return Response({"detail": detail, "errors": errors}, status=400)
|
||||
message = " ".join(error.messages)
|
||||
return Response({"detail": message, "errors": {"password": message}}, status=400)
|
||||
|
||||
|
||||
def _user_payload(user: HumanUser) -> dict[str, object]:
|
||||
memberships = []
|
||||
routes = membership_routes_for_user(user.id)
|
||||
organizations = Organization.objects.in_bulk(
|
||||
[route.organization_id for route in routes]
|
||||
)
|
||||
for route in routes:
|
||||
organization = organizations.get(route.organization_id)
|
||||
if organization is None:
|
||||
continue
|
||||
with tenant_atomic(organization.id):
|
||||
# Организация читается вместе с членством внутри её контекста: роль
|
||||
# app не видит чужие строки организаций (tenancy/0033).
|
||||
with tenant_atomic(route.organization_id):
|
||||
membership = (
|
||||
OrganizationMembership.objects.select_related("organization")
|
||||
.filter(
|
||||
id=route.resource_id,
|
||||
user=user,
|
||||
organization=organization,
|
||||
organization_id=route.organization_id,
|
||||
blocked_at__isnull=True,
|
||||
)
|
||||
.first()
|
||||
@@ -62,7 +79,16 @@ def _user_payload(user: HumanUser) -> dict[str, object]:
|
||||
"deliveryMode": settings.CHATBALLS_DELIVERY_MODE,
|
||||
"uiTheme": user.ui_theme,
|
||||
"uiAccent": user.ui_accent,
|
||||
# Личный выбор языка: пустая строка — «как в организации». Отдельно
|
||||
# отдаётся язык, на котором интерфейс открывается прямо сейчас, — он
|
||||
# уже разрешён по цепочке профиль → организация → установка, и
|
||||
# фронтенду не нужно повторять это правило у себя.
|
||||
"uiLanguage": user.ui_language,
|
||||
"language": current_language(),
|
||||
"avatarUrl": own_avatar_url(user),
|
||||
# Администратор установки видит раздел «Платформа» и хранилище
|
||||
# файлов: это свойства инсталляции, а не организации.
|
||||
"isInstanceAdmin": user.is_instance_admin,
|
||||
"memberships": memberships,
|
||||
}
|
||||
|
||||
|
||||
@@ -1,16 +1,87 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from django.contrib.auth import login
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.utils.decorators import method_decorator
|
||||
from django.views.decorators.csrf import csrf_protect, ensure_csrf_cookie
|
||||
from rest_framework.permissions import AllowAny, IsAuthenticated
|
||||
from rest_framework.request import Request
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.throttling import ScopedRateThrottle
|
||||
from rest_framework.views import APIView
|
||||
|
||||
from chatballs.identity.auth.common import _user_payload
|
||||
from chatballs.identity.invitation_service import InvitationError, accept_invitation
|
||||
from chatballs.i18n import t
|
||||
from chatballs.identity.auth.common import _user_payload, validation_response
|
||||
from chatballs.identity.invitation_service import (
|
||||
InvitationError,
|
||||
accept_invitation,
|
||||
invitation_preview,
|
||||
register_and_accept,
|
||||
)
|
||||
from chatballs.identity.sessions import remember_device
|
||||
|
||||
|
||||
@method_decorator(ensure_csrf_cookie, name="dispatch")
|
||||
class InvitationPreviewView(APIView):
|
||||
"""Что стоит за ссылкой /join до входа: организация, адрес, есть ли учётная запись.
|
||||
|
||||
Ответ нужен экрану, чтобы решить, показать вход или форму создания пароля.
|
||||
Токен — секрет из письма, поэтому подробности отдаются только по нему.
|
||||
"""
|
||||
|
||||
authentication_classes: list = []
|
||||
permission_classes = [AllowAny]
|
||||
throttle_classes = [ScopedRateThrottle]
|
||||
throttle_scope = "login"
|
||||
|
||||
def get(self, request: Request) -> Response:
|
||||
token = str(request.query_params.get("token", "")).strip()
|
||||
preview = invitation_preview(token) if token else None
|
||||
if preview is None:
|
||||
return Response({"valid": False})
|
||||
return Response({"valid": True, **preview})
|
||||
|
||||
|
||||
@method_decorator(csrf_protect, name="dispatch")
|
||||
class InvitationRegisterView(APIView):
|
||||
"""Создать учётную запись по приглашению, принять его и войти."""
|
||||
|
||||
authentication_classes: list = []
|
||||
permission_classes = [AllowAny]
|
||||
throttle_classes = [ScopedRateThrottle]
|
||||
throttle_scope = "login"
|
||||
|
||||
def post(self, request: Request) -> Response:
|
||||
body = request.data if isinstance(request.data, dict) else {}
|
||||
token = str(body.get("token", "")).strip()
|
||||
if not token:
|
||||
return Response({"detail": t("identity.token_required")}, status=400)
|
||||
try:
|
||||
accepted = register_and_accept(
|
||||
token=token,
|
||||
full_name=str(body.get("fullName", "")),
|
||||
password=str(body.get("password", "")),
|
||||
)
|
||||
except InvitationError as error:
|
||||
return Response({"detail": str(error), "code": error.code}, status=400)
|
||||
except ValidationError as error:
|
||||
return validation_response(error)
|
||||
user = accepted.membership.user
|
||||
user.backend = "django.contrib.auth.backends.ModelBackend"
|
||||
login(request, user)
|
||||
remember_device(request)
|
||||
return Response(
|
||||
{
|
||||
"authenticated": True,
|
||||
"user": _user_payload(user),
|
||||
"organizationPublicId": str(accepted.organization.public_id),
|
||||
},
|
||||
status=201,
|
||||
)
|
||||
|
||||
|
||||
class InvitationAcceptView(APIView):
|
||||
"""Accept an OWNER invitation (SPEC-HUB-0021 §8.2).
|
||||
"""Accept an organization invitation: OWNER (SPEC-HUB-0021 §8.2) or employee.
|
||||
|
||||
Authenticated endpoint: the caller must already have a HumanUser account
|
||||
(created through sign-up / password setup). The token is read from the body;
|
||||
@@ -23,9 +94,15 @@ class InvitationAcceptView(APIView):
|
||||
def post(self, request: Request) -> Response:
|
||||
token = str(request.data.get("token", "")).strip()
|
||||
if not token:
|
||||
return Response({"detail": "token is required"}, status=400)
|
||||
return Response({"detail": t("identity.token_required")}, status=400)
|
||||
try:
|
||||
accept_invitation(token=token, user=request.user)
|
||||
accepted = accept_invitation(token=token, user=request.user)
|
||||
except InvitationError as error:
|
||||
return Response({"detail": str(error)}, status=400)
|
||||
return Response({"user": _user_payload(request.user)})
|
||||
return Response(
|
||||
{
|
||||
"user": _user_payload(request.user),
|
||||
# Куда открыть приложение после принятия.
|
||||
"organizationPublicId": str(accepted.organization.public_id),
|
||||
}
|
||||
)
|
||||
@@ -13,6 +13,7 @@ from rest_framework.throttling import ScopedRateThrottle
|
||||
from rest_framework.views import APIView
|
||||
|
||||
from chatballs.events.services import DomainEvent, enqueue_event
|
||||
from chatballs.i18n import t
|
||||
from chatballs.identity.audit import record_audit_event
|
||||
from chatballs.identity.event_handlers import PASSWORD_RESET_REQUESTED
|
||||
from chatballs.identity.models import AuditResult, HumanUser
|
||||
@@ -87,7 +88,7 @@ class PasswordResetConfirmView(APIView):
|
||||
user = _user_from_reset_link(str(body.get("uid", "")), str(body.get("token", "")))
|
||||
if user is None:
|
||||
record_audit_event(action="identity.password_reset_failed", result=AuditResult.DENIED, request=request)
|
||||
return Response({"detail": "Ссылка недействительна или истекла"}, status=400)
|
||||
return Response({"detail": t("identity.link_invalid_or_expired")}, status=400)
|
||||
|
||||
new_password = str(body.get("newPassword", ""))
|
||||
try:
|
||||
|
||||
@@ -2,17 +2,20 @@ from django.contrib.auth import login
|
||||
from django.contrib.auth.password_validation import validate_password
|
||||
from django.core.exceptions import ValidationError as DjangoValidationError
|
||||
from django.http import FileResponse
|
||||
from django.utils import timezone
|
||||
from django.utils import timezone, translation
|
||||
from rest_framework.parsers import FormParser, MultiPartParser
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from rest_framework.request import Request
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
|
||||
from chatballs.i18n import t
|
||||
from chatballs.i18n.languages import normalize_language, resolve_language
|
||||
from chatballs.identity.audit import record_audit_event
|
||||
from chatballs.identity.auth.common import _revoke_other_user_sessions, _user_payload
|
||||
from chatballs.identity.avatars import delete_user_avatar, replace_user_avatar
|
||||
from chatballs.identity.models import HumanUser
|
||||
from chatballs.identity.instance_settings import default_language
|
||||
from chatballs.identity.models import HumanUser, OrganizationMembership
|
||||
from chatballs.identity.sessions import list_user_sessions
|
||||
from chatballs.tenancy.ingress import user_requires_totp
|
||||
|
||||
@@ -25,11 +28,11 @@ class ProfileUpdateView(APIView):
|
||||
full_name = str(body.get("fullName", "")).strip()
|
||||
email = HumanUser.objects.normalize_email(str(body.get("email", "")).strip())
|
||||
if not full_name:
|
||||
return Response({"detail": "Full name is required"}, status=400)
|
||||
return Response({"detail": t("admin.full_name_required")}, status=400)
|
||||
if not email:
|
||||
return Response({"detail": "Email is required"}, status=400)
|
||||
return Response({"detail": t("admin.email_required")}, status=400)
|
||||
if HumanUser.objects.exclude(id=request.user.id).filter(email=email).exists():
|
||||
return Response({"detail": "Email is already used"}, status=400)
|
||||
return Response({"detail": t("admin.email_taken")}, status=400)
|
||||
|
||||
request.user.full_name = full_name
|
||||
request.user.email = email
|
||||
@@ -53,7 +56,7 @@ class ProfileAvatarView(APIView):
|
||||
def get(self, request: Request) -> Response | FileResponse:
|
||||
user = request.user
|
||||
if not user.avatar:
|
||||
return Response({"detail": "Фото не задано"}, status=404)
|
||||
return Response({"detail": t("profile.photo_not_set")}, status=404)
|
||||
return FileResponse(
|
||||
user.avatar.open("rb"),
|
||||
content_type=user.avatar_content_type or "application/octet-stream",
|
||||
@@ -63,7 +66,7 @@ class ProfileAvatarView(APIView):
|
||||
def post(self, request: Request) -> Response:
|
||||
upload = request.FILES.get("file")
|
||||
if upload is None:
|
||||
return Response({"detail": "Выберите файл фото"}, status=400)
|
||||
return Response({"detail": t("profile.choose_photo_file")}, status=400)
|
||||
replace_user_avatar(request.user, upload)
|
||||
record_audit_event(
|
||||
action="identity.avatar_updated",
|
||||
@@ -94,7 +97,7 @@ class ProfilePasswordView(APIView):
|
||||
current_password = str(body.get("currentPassword", ""))
|
||||
new_password = str(body.get("newPassword", ""))
|
||||
if not request.user.check_password(current_password):
|
||||
return Response({"detail": "Current password is invalid"}, status=400)
|
||||
return Response({"detail": t("profile.current_password_invalid")}, status=400)
|
||||
try:
|
||||
validate_password(new_password, user=request.user)
|
||||
except DjangoValidationError as error:
|
||||
@@ -128,7 +131,7 @@ class ProfileTotpStartView(APIView):
|
||||
def post(self, request: Request) -> Response:
|
||||
if request.user.totp_enabled:
|
||||
return Response(
|
||||
{"detail": "TOTP уже включена: сначала отключите её текущим паролем"},
|
||||
{"detail": t("profile.totp_already_on")},
|
||||
status=409,
|
||||
)
|
||||
request.user.totp_secret = ""
|
||||
@@ -147,10 +150,10 @@ class ProfileTotpDisableView(APIView):
|
||||
def post(self, request: Request) -> Response:
|
||||
current_password = str(request.data.get("currentPassword", ""))
|
||||
if not request.user.check_password(current_password):
|
||||
return Response({"detail": "Current password is invalid"}, status=400)
|
||||
return Response({"detail": t("profile.current_password_invalid")}, status=400)
|
||||
|
||||
if user_requires_totp(request.user.id):
|
||||
return Response({"detail": "TOTP is required by an organization policy"}, status=409)
|
||||
return Response({"detail": t("profile.totp_required_by_policy")}, status=409)
|
||||
request.user.totp_enabled = False
|
||||
request.user.totp_secret = ""
|
||||
request.user.save(update_fields=["totp_enabled", "totp_secret"])
|
||||
@@ -196,7 +199,7 @@ class ChangeTemporaryPasswordView(APIView):
|
||||
current_password = str(body.get("currentPassword", ""))
|
||||
new_password = str(body.get("newPassword", ""))
|
||||
if not request.user.must_change_password and not request.user.check_password(current_password):
|
||||
return Response({"detail": "Current password is invalid"}, status=400)
|
||||
return Response({"detail": t("profile.current_password_invalid")}, status=400)
|
||||
try:
|
||||
validate_password(new_password, user=request.user)
|
||||
except DjangoValidationError as error:
|
||||
@@ -230,10 +233,66 @@ class ProfileAppearanceView(APIView):
|
||||
theme = str(body.get("theme", request.user.ui_theme)).strip().upper()
|
||||
accent = str(body.get("accent", request.user.ui_accent)).strip().lower()
|
||||
if theme not in UiTheme.values:
|
||||
return Response({"detail": "Неизвестная тема"}, status=400)
|
||||
return Response({"detail": t("profile.unknown_theme")}, status=400)
|
||||
if accent and not re.fullmatch(r"#[0-9a-f]{6}", accent):
|
||||
return Response({"detail": "Акцент — HEX-цвет вида #1677ff"}, status=400)
|
||||
return Response({"detail": t("profile.accent_hex")}, status=400)
|
||||
request.user.ui_theme = theme
|
||||
request.user.ui_accent = accent
|
||||
request.user.save(update_fields=["ui_theme", "ui_accent"])
|
||||
return Response({"authenticated": True, "user": _user_payload(request.user)})
|
||||
|
||||
|
||||
class ProfileLanguageView(APIView):
|
||||
"""Язык интерфейса — личная настройка сотрудника.
|
||||
|
||||
Пустое значение возвращает человека к языку организации: это не «русский»,
|
||||
а снятие личного выбора, и после смены языка организации такой сотрудник
|
||||
поедет за ней, а тот, кто выбрал язык явно, — нет.
|
||||
"""
|
||||
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def post(self, request: Request) -> Response:
|
||||
raw = str(request.data.get("language", "")).strip()
|
||||
language = normalize_language(raw)
|
||||
if raw and not language:
|
||||
return Response({"detail": t("settings.language_unsupported")}, status=400)
|
||||
request.user.ui_language = language
|
||||
request.user.save(update_fields=["ui_language"])
|
||||
record_audit_event(
|
||||
action="identity.profile_language_changed",
|
||||
actor=request.user,
|
||||
object_type="HumanUser",
|
||||
object_id=str(request.user.id),
|
||||
request=request,
|
||||
)
|
||||
# Ответ уже на новом языке: интерфейс перерисуется по нему сразу, не
|
||||
# дожидаясь следующего запроса.
|
||||
resolved = resolve_language(
|
||||
user_language=language,
|
||||
organization_language=_request_organization_language(request),
|
||||
instance_language=default_language(),
|
||||
accept_language=request.headers.get("Accept-Language"),
|
||||
)
|
||||
with translation.override(resolved):
|
||||
return Response({"authenticated": True, "user": _user_payload(request.user)})
|
||||
|
||||
|
||||
def _request_organization_language(request: Request) -> str:
|
||||
"""Язык организации запроса, если он у этого запроса вообще есть.
|
||||
|
||||
Профиль открывается вне организации, поэтому tenant_context здесь обычно
|
||||
пуст: тогда язык берётся из единственного членства, а при нескольких —
|
||||
из того, что старше, чтобы ответ не зависел от порядка выборки.
|
||||
"""
|
||||
|
||||
context = getattr(request, "tenant_context", None)
|
||||
if context is not None:
|
||||
return context.organization.language or ""
|
||||
membership = (
|
||||
OrganizationMembership.objects.select_related("organization")
|
||||
.filter(user=request.user, blocked_at__isnull=True)
|
||||
.order_by("created_at", "id")
|
||||
.first()
|
||||
)
|
||||
return membership.organization.language if membership is not None else ""
|
||||
@@ -9,6 +9,7 @@ from rest_framework.response import Response
|
||||
from rest_framework.throttling import ScopedRateThrottle
|
||||
from rest_framework.views import APIView
|
||||
|
||||
from chatballs.i18n import current_language, t
|
||||
from chatballs.identity.audit import record_audit_event
|
||||
from chatballs.identity.auth.common import _challenge_payload, _user_payload
|
||||
from chatballs.identity.auth.totp_utils import TOTP_SESSION_KEY, TOTP_STARTED_KEY
|
||||
@@ -22,7 +23,12 @@ class SessionView(APIView):
|
||||
|
||||
def get(self, request: Request) -> Response:
|
||||
if not request.user.is_authenticated:
|
||||
return Response({"authenticated": False})
|
||||
# Язык установки нужен и до входа: логин и сброс пароля — экраны
|
||||
# самой коробки, и на английской машине они должны открываться на
|
||||
# языке установки, а не на языке браузера. Без этого фронтенд знал
|
||||
# бы только про браузер и расходился бы с бэкендом, который язык
|
||||
# установки учитывает.
|
||||
return Response({"authenticated": False, "language": current_language()})
|
||||
return Response({"authenticated": True, "user": _user_payload(request.user)})
|
||||
|
||||
|
||||
@@ -42,7 +48,7 @@ class LoginView(APIView):
|
||||
user = authenticate(request, username=email, password=password)
|
||||
if user is None:
|
||||
record_audit_event(action="identity.login_failed", result=AuditResult.DENIED, request=request)
|
||||
return Response({"detail": "Invalid credentials"}, status=401)
|
||||
return Response({"detail": t("identity.invalid_credentials")}, status=401)
|
||||
if user.totp_enabled:
|
||||
request.session[TOTP_SESSION_KEY] = user.id
|
||||
# Шаг с кодом ждёт не вечно: см. TOTP_CHALLENGE_TTL_SECONDS.
|
||||
|
||||
@@ -10,6 +10,7 @@ from rest_framework.response import Response
|
||||
from rest_framework.throttling import ScopedRateThrottle
|
||||
from rest_framework.views import APIView
|
||||
|
||||
from chatballs.i18n import t
|
||||
from chatballs.identity.audit import record_audit_event
|
||||
from chatballs.identity.auth.common import _user_payload
|
||||
from chatballs.identity.auth.totp_utils import (
|
||||
@@ -43,7 +44,7 @@ class TotpSetupView(APIView):
|
||||
|
||||
def get(self, request: Request) -> Response:
|
||||
if request.user.totp_enabled:
|
||||
return Response({"detail": "TOTP is already enabled"}, status=400)
|
||||
return Response({"detail": t("profile.totp_enabled_already")}, status=400)
|
||||
|
||||
secret = _ensure_totp_secret(request.user)
|
||||
account_name = request.user.email
|
||||
@@ -76,7 +77,7 @@ class TotpConfirmView(APIView):
|
||||
result=AuditResult.DENIED,
|
||||
request=request,
|
||||
)
|
||||
return Response({"detail": "Invalid TOTP code"}, status=400)
|
||||
return Response({"detail": t("identity.invalid_totp_code")}, status=400)
|
||||
|
||||
request.user.totp_enabled = True
|
||||
request.user.save(update_fields=["totp_enabled"])
|
||||
@@ -99,7 +100,7 @@ class TotpVerifyView(APIView):
|
||||
pending_user_id = request.session.get(TOTP_SESSION_KEY)
|
||||
if not pending_user_id or _challenge_expired(request):
|
||||
_drop_challenge(request)
|
||||
return Response({"detail": "TOTP challenge is not active"}, status=401)
|
||||
return Response({"detail": t("identity.totp_challenge_inactive")}, status=401)
|
||||
|
||||
try:
|
||||
# is_active обязателен: пароль приняли раньше, и без этой проверки
|
||||
@@ -109,7 +110,7 @@ class TotpVerifyView(APIView):
|
||||
)
|
||||
except HumanUser.DoesNotExist:
|
||||
_drop_challenge(request)
|
||||
return Response({"detail": "TOTP challenge is not active"}, status=401)
|
||||
return Response({"detail": t("identity.totp_challenge_inactive")}, status=401)
|
||||
|
||||
if not user.totp_enabled or not accept_totp_code(
|
||||
user, str(request.data.get("code", ""))
|
||||
@@ -120,7 +121,7 @@ class TotpVerifyView(APIView):
|
||||
result=AuditResult.DENIED,
|
||||
request=request,
|
||||
)
|
||||
return Response({"detail": "Invalid TOTP code"}, status=400)
|
||||
return Response({"detail": t("identity.invalid_totp_code")}, status=400)
|
||||
|
||||
# Отметку «последний код принят …» для карточки 2FA (кадр P1) уже
|
||||
# проставил accept_totp_code вместе с номером интервала.
|
||||
|
||||
@@ -11,6 +11,7 @@ urlpatterns = [
|
||||
path("password-reset/confirm/", auth.PasswordResetConfirmView.as_view(), name="auth-password-reset-confirm"),
|
||||
path("profile/update/", auth.ProfileUpdateView.as_view(), name="auth-profile-update"),
|
||||
path("profile/appearance/", auth.ProfileAppearanceView.as_view(), name="auth-profile-appearance"),
|
||||
path("profile/language/", auth.ProfileLanguageView.as_view(), name="auth-profile-language"),
|
||||
path("profile/avatar/", auth.ProfileAvatarView.as_view(), name="auth-profile-avatar"),
|
||||
path("profile/password/", auth.ProfilePasswordView.as_view(), name="auth-profile-password"),
|
||||
path("profile/totp/start/", auth.ProfileTotpStartView.as_view(), name="auth-profile-totp-start"),
|
||||
@@ -22,4 +23,6 @@ urlpatterns = [
|
||||
path("totp/confirm/", auth.TotpConfirmView.as_view(), name="auth-totp-confirm"),
|
||||
path("totp/verify/", auth.TotpVerifyView.as_view(), name="auth-totp-verify"),
|
||||
path("invitations/accept/", auth.InvitationAcceptView.as_view(), name="auth-invitation-accept"),
|
||||
path("invitations/preview/", auth.InvitationPreviewView.as_view(), name="auth-invitation-preview"),
|
||||
path("invitations/register/", auth.InvitationRegisterView.as_view(), name="auth-invitation-register"),
|
||||
]
|
||||
@@ -16,6 +16,7 @@ from django.core.files.base import ContentFile
|
||||
from django.core.files.uploadedfile import UploadedFile
|
||||
from rest_framework.exceptions import ValidationError
|
||||
|
||||
from chatballs.i18n import t
|
||||
from chatballs.identity.models import HumanUser
|
||||
|
||||
MAX_AVATAR_BYTES = 2 * 1024 * 1024
|
||||
@@ -37,9 +38,12 @@ def user_avatar_url(user: HumanUser | None, organization_public_id) -> str | Non
|
||||
@functools.lru_cache(maxsize=4096)
|
||||
def organization_public_id(organization_id: int) -> str:
|
||||
"""public_id организации по id — неизменяем, поэтому кэшируется."""
|
||||
from chatballs.identity.models import Organization
|
||||
from chatballs.tenancy.ingress import organization_public_id_of
|
||||
|
||||
return str(Organization.objects.values_list("public_id", flat=True).get(pk=organization_id))
|
||||
public_id = organization_public_id_of(organization_id)
|
||||
if public_id is None:
|
||||
raise LookupError(f"Organization {organization_id} does not exist")
|
||||
return public_id
|
||||
|
||||
|
||||
def user_avatar_url_in(user: HumanUser | None, organization_id: int) -> str | None:
|
||||
@@ -65,12 +69,12 @@ def image_type(data: bytes) -> tuple[str, str] | None:
|
||||
def replace_user_avatar(user: HumanUser, upload: UploadedFile) -> HumanUser:
|
||||
data = upload.read()
|
||||
if not data:
|
||||
raise ValidationError({"file": "Выберите файл фото"})
|
||||
raise ValidationError({"file": t("profile.choose_photo_file")})
|
||||
if len(data) > MAX_AVATAR_BYTES:
|
||||
raise ValidationError({"file": "Размер фото не должен превышать 2 МБ"})
|
||||
raise ValidationError({"file": t("profile.photo_too_large")})
|
||||
detected = image_type(data)
|
||||
if detected is None:
|
||||
raise ValidationError({"file": "Поддерживаются PNG, JPEG и WebP"})
|
||||
raise ValidationError({"file": t("admin.image_formats")})
|
||||
content_type, suffix = detected
|
||||
return set_user_avatar(user, data, content_type, suffix)
|
||||
|
||||
|
||||
@@ -50,15 +50,17 @@ def bootstrap_owner(*, email: str, password: str, full_name: str = "") -> Bootst
|
||||
"full_name": full_name,
|
||||
"is_staff": True,
|
||||
"is_superuser": True,
|
||||
"is_instance_admin": True,
|
||||
},
|
||||
)
|
||||
if created_owner:
|
||||
owner.set_password(password)
|
||||
owner.save(update_fields=["password"])
|
||||
elif not owner.is_staff or not owner.is_superuser:
|
||||
elif not owner.is_staff or not owner.is_superuser or not owner.is_instance_admin:
|
||||
owner.is_staff = True
|
||||
owner.is_superuser = True
|
||||
owner.save(update_fields=["is_staff", "is_superuser"])
|
||||
owner.is_instance_admin = True
|
||||
owner.save(update_fields=["is_staff", "is_superuser", "is_instance_admin"])
|
||||
|
||||
OrganizationMembership.objects.get_or_create(
|
||||
user=owner,
|
||||
|
||||
@@ -4,10 +4,8 @@ from chatballs.identity import (
|
||||
administration_views,
|
||||
demo_views,
|
||||
group_views,
|
||||
instance_views,
|
||||
)
|
||||
from chatballs.integrations import feature_views
|
||||
from chatballs.tenancy import storage_views
|
||||
|
||||
urlpatterns = [
|
||||
path("groups/", group_views.GroupListView.as_view(), name="group-list"),
|
||||
@@ -17,16 +15,8 @@ urlpatterns = [
|
||||
administration_views.OrganizationSettingsView.as_view(),
|
||||
name="organization-settings",
|
||||
),
|
||||
path(
|
||||
"administration/instance/",
|
||||
instance_views.InstanceAddressView.as_view(),
|
||||
name="instance-address",
|
||||
),
|
||||
path(
|
||||
"administration/instance/email-check/",
|
||||
instance_views.InstanceEmailCheckView.as_view(),
|
||||
name="instance-email-check",
|
||||
),
|
||||
# Настройки установки (адрес, почта, TURN, хранилище) — не свойства
|
||||
# организации: они живут на /api/v1/instance/ (identity.instance_urls).
|
||||
path(
|
||||
"administration/logo/",
|
||||
administration_views.OrganizationLogoView.as_view(),
|
||||
@@ -39,9 +29,6 @@ urlpatterns = [
|
||||
),
|
||||
path("demo/", demo_views.DemoDataView.as_view(), name="organization-demo-data"),
|
||||
path("administration/communication/", feature_views.CommunicationSettingsView.as_view(), name="communication-settings"),
|
||||
path("administration/storage/", storage_views.StorageSettingsView.as_view(), name="storage-settings"),
|
||||
path("administration/storage/check/", storage_views.StorageCheckView.as_view(), name="storage-check"),
|
||||
path("administration/storage/migrate/", storage_views.StorageMigrateView.as_view(), name="storage-migrate"),
|
||||
path(
|
||||
"administration/audit/",
|
||||
administration_views.AuditListView.as_view(),
|
||||
|
||||
Loaded 100 of 447 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user