Compare commits

...
3 Commits
Author SHA1 Message Date
AndreyandClaude Opus 5 d7f87719de 🔖 release: 1.1.0
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 00:37:21 +03:00
AndreyandClaude Opus 5 6c3dec7339 🌐 feat(i18n): язык интерфейса — русский и английский
Интерфейс был русским в коде: строки лежали прямо в JSX и в ответах API,
даты форматировались прибитым «ru-RU», склонение по числу писалось руками
в каждом файле. Теперь текста в коде нет — он живёт в словарях, а язык
выбирается человеком.

Цепочка одна на обе стороны: профиль сотрудника → организация → установка
→ браузер. Пустое значение на каждом уровне значит «как выше», а не
«русский»: тот, кто язык не трогал, поедет за организацией, когда владелец
её переключит, а выбравший явно — останется на своём.

Владелец задаёт язык организации («Настройки» → «Организация», рядом с
часовым поясом) и язык установки («Платформа») — на нём открываются вход,
сброс пароля и мастер первого запуска. Сотрудник переопределяет его в
профиле.

Русский каталог задаёт набор ключей, английский обязан его повторить:
во фронтенде это ловит tsc — пропущенный перевод становится ошибкой
сборки, — на бэкенде тест каталога, который заодно сверяет имена
параметров в фразах. Формы множественного числа берутся из CLDR через
Intl.PluralRules, даты и размеры — через Intl, а не через свои списки
месяцев.

Отдельно пришлось разобраться с тем, что уже записано в базу. Системные
события диалога писались готовой русской фразой, и перевести историю
задним числом нельзя — теперь пишется код события, а фразу собирает
сервер на языке читателя. То же с уведомлениями: они адресованы
операторам, а не одному человеку, и в смешанной команде готовая фраза
неверна для половины. Тон системной строки в треде и признак анонимного
посетителя больше не угадываются регуляркой по русским словам: под
английским бэкендом это просто перестало бы работать.

Письма уходят на языке получателя, а не отправителя: приглашение
отправляет владелец, а читает другой человек.

gettext не взят намеренно: .po/.mo потребовали бы msgfmt в сборке образа
ради того же результата, что дают обычные словари, одинаковые на обеих
сторонах. Язык запроса при этом активируется штатным механизмом Django,
поэтому сообщения DRF переводятся тоже.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 00:36:52 +03:00
AndreyandClaude Fable 5.1 f91b3da27a 📄 docs(license): AGPL-3.0
Текст лицензии в LICENSE, раздел и бейдж в обоих README.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-09 19:52:11 +03:00
286 changed files with 8863 additions and 2562 deletions

No files matched your search

+8
View File
@@ -44,6 +44,14 @@ Engineering rules / обязательные практики:
- Не инлайнить сложный UI повторно. Выносить компонент, если структура повторяется или секция имеет отдельную ответственность.
- Если реализация по задаче конфликтует с этими правилами, остановиться и явно сообщить blocker вместо поставки плохой структуры.
Язык интерфейса:
- Текста, который видит человек, в коде не бывает. Строка живёт в словаре и подставляется через `t(...)`: во фронтенде это `src/i18n` приложения (`packages/ui` — свой словарь), на бэкенде `chatballs/i18n`. Русский каталог — источник ключей, английский обязан его повторить: пропущенный перевод во фронтенде ловит `tsc`, на бэкенде — тест каталога.
- Ключ пишется от смысла, а не от фразы: `settings.storage_bucket_name`, а не пересказ текста. Строку переформулируют чаще, чем переименовывают.
- Число не склоняют вручную: формы задаёт словарь, форму выбирает `tn(...)`. Дату, размер файла и разряды числа форматирует `fmt` — прибитые `"ru-RU"` и свои списки месяцев запрещены.
- Что записано в базу, переводится по коду, а не текстом: системные события диалога, уведомления и подписи журнала аудита хранят код, а фразу собирает бэкенд на языке читателя. Писать в историю готовую русскую фразу нельзя — её потом не перевести.
- Язык запроса выбирается цепочкой: профиль сотрудника → организация → установка → браузер. Письмо получает язык адресата, а не отправителя.
- На бэкенде `t(...)` не вызывается на уровне модуля: язык там свой на каждый запрос, а константа посчиталась бы один раз при импорте.
Definition of Done:
- Запускать только тесты, относящиеся к изменениям текущей итерации. Полный прогон всех тестов выполнять только по явному указанию владельца.
- Измененные файлы должны быть проверены на NO GOD violations.
+661
View File
@@ -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/>.
+8
View File
@@ -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)
---
@@ -321,3 +323,9 @@ Copy the `chatballs-postgres`, `chatballs-media` and `chatballs-secrets` volumes
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.
+8
View File
@@ -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 @@
- [Обновление](#обновление)
- [Функции](#функции)
- [Решение проблем](#решение-проблем)
- [Лицензия](#лицензия)
---
@@ -321,3 +323,9 @@ docker compose ps
docker compose exec -T postgres pg_dump -U chatballs_bootstrap chatballs > backup.sql
```
</details>
---
## Лицензия
Chatballs распространяется по лицензии [GNU Affero General Public License v3.0](LICENSE). Продукт можно свободно использовать, менять и ставить у себя. Если вы меняете его и предоставляете другим как сервис, изменения нужно опубликовать под той же лицензией.
+1 -1
View File
@@ -1 +1 @@
1.0.0
1.1.0
+2 -1
View File
@@ -22,6 +22,7 @@ from chatballs.channels.services import (
update_channel,
)
from chatballs.conversations.models import LifecycleState
from chatballs.i18n import t
from chatballs.tenancy.context import TenantContext
@@ -170,7 +171,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:
+14 -9
View File
@@ -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:
@@ -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,10 +239,10 @@ 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)
@@ -263,7 +268,7 @@ 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):
@@ -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,
+2 -1
View File
@@ -22,6 +22,7 @@ from chatballs.ai.provider.base import (
LLMProvider,
ProviderError,
)
from chatballs.i18n import t
EMBEDDING_DIM = 16
KNOWLEDGE_MARKER = "Отвечай только на основе этих знаний:"
@@ -131,4 +132,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)
@@ -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):
@@ -70,5 +71,5 @@ class OpenRouterProvider(LLMProvider):
raise ProviderError(f"Расшифровка не удалась: {error}") from error
text = str(payload.get("text") or "").strip()
if not text:
raise ProviderError("Провайдер вернул пустую расшифровку")
raise ProviderError(t("ai.empty_transcript"))
return text
+2 -1
View File
@@ -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
@@ -183,7 +184,7 @@ def set_agent_active(*, context: TenantContext, agent: AIAgent, is_active: bool)
raise ValidationError({"agent": "Archived AI agent cannot change 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
+3 -1
View File
@@ -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
@@ -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)
+31 -2
View File
@@ -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,7 +100,9 @@ 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")
@@ -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 -2
View File
@@ -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:
+10 -9
View File
@@ -26,6 +26,7 @@ from chatballs.calls.tokens import (
issue_call_access_token,
verify_call_access_token,
)
from chatballs.i18n import t
from chatballs.identity.models import Organization, OrganizationMembership
from chatballs.tenancy.context import TenantContext
from chatballs.tenancy.database import tenant_atomic
@@ -39,7 +40,7 @@ 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:
@@ -90,11 +91,11 @@ 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")
raise CallTokenError(t("calls.token_invalid"))
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")) from None
resource_context = TenantContext.for_resource(organization)
with tenant_atomic(resource_context):
try:
@@ -102,9 +103,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 +134,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 +171,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 +187,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 +206,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:
+24 -18
View File
@@ -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")
@@ -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
+2 -1
View File
@@ -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)
+13 -12
View File
@@ -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
@@ -38,9 +39,9 @@ def require_organization_manage(context: TenantContext, *, operation: str) -> No
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"))
+2 -1
View File
@@ -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}
@@ -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
# Исполняемые и скриптовые типы в чат не отправляем ни в одну сторону.
@@ -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,10 +83,10 @@ 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:
@@ -94,7 +95,7 @@ class ConversationContactView(ConversationViewBase):
if len(value) > limit:
return Response({"detail": f"Поле {field}: не длиннее {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,7 +147,7 @@ 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
@@ -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)
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(
@@ -278,14 +283,18 @@ 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, "Неизвестно"),
"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 +334,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 +363,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}),
@@ -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
+35 -2
View File
@@ -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),
),
]
@@ -257,6 +257,33 @@ class MessageAuthor(models.TextChoices):
SYSTEM = "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):
TEXT = "", "Текст"
CONTACT_REQUEST = "contact_request", "Запрос контакта"
@@ -300,6 +327,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)
@@ -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,14 +42,14 @@ 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:
@@ -57,7 +58,7 @@ class ClientDetailView(ConversationViewBase):
if len(value) > limit:
return Response({"detail": f"Поле {field}: не длиннее {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,7 +11,9 @@ from chatballs.conversations.models import (
Message,
MessageAuthor,
MessageKind,
SystemEvent,
)
from chatballs.i18n import t
from chatballs.identity.models import EmployeeRole
from chatballs.integrations.models import IntegrationProvider
from chatballs.tenancy.context import TenantContext
@@ -36,7 +38,7 @@ def _require_open(conversation: Conversation) -> None:
if conversation.lifecycle != LifecycleState.OPEN:
raise ClaimError("Диалог закрыт")
raise ClaimError(t("conversations.closed"))
@@ -76,7 +78,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 +100,7 @@ def claim_locked_conversation(*, context: TenantContext, conversation: Conversat
):
raise ClaimError("Диалог уже ведёт другой оператор")
raise ClaimError(t("calls.conversation_taken"))
conversation.control_mode = ControlMode.HUMAN
@@ -140,7 +142,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 +152,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 +187,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
@@ -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
+21 -20
View File
@@ -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)
@@ -145,7 +146,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 +175,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 +201,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 +227,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 +265,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 +285,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 +314,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 +340,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 +365,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 +391,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 +419,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 +427,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 +449,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 +463,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,
+31
View File
@@ -0,0 +1,31 @@
"""Язык интерфейса: выбор языка запроса и каталог сообщений."""
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",
"first_chosen",
"normalize_language",
"parse_accept_language",
"resolve_language",
"t",
"tn",
]
+97
View File
@@ -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)
+113
View File
@@ -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"]
+304
View File
@@ -0,0 +1,304 @@
"""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": "audio call",
"calls.kind_invalid": "Unsupported call type",
"calls.kind_required": "Choose the call type",
"calls.kind_video": "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",
"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_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",
}
+308
View File
@@ -0,0 +1,308 @@
"""Русский каталог — исходный. Ключ появляется здесь, потом в 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",
"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_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": "Диалог недоступен",
}
+62
View File
@@ -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()
+127
View File
@@ -0,0 +1,127 @@
"""Проверки языка интерфейса.
Главная из них — совпадение наборов ключей в каталогах. Пропущенный перевод не
роняет запрос (строка выходит по-русски), и именно поэтому его легко не
заметить: без теста английский каталог отстаёт молча.
"""
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_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"])
@@ -4,12 +4,13 @@ from zoneinfo import available_timezones
from django.urls import reverse
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 +43,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 +62,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]:
"""Событие журнала. Отдаём и код действия, и подпись: подписи может не быть
(тогда интерфейс показывает код), а код нужен для поиска в любом случае."""
@@ -78,7 +93,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,8 @@ 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.models import Organization
from chatballs.tenancy.context import TenantContext
from chatballs.tenancy.storage import adjust_storage_usage
@@ -26,27 +28,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 +73,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
@@ -84,7 +96,7 @@ 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 МБ"})
detected = _image_type(data)
@@ -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
@@ -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,7 +106,7 @@ class OrganizationLogoView(APIView):
def get(self, request: Request):
organization = request.tenant_context.organization
if not organization.logo:
return Response({"detail": "Логотип не загружен"}, status=404)
return Response({"detail": t("admin.logo_not_uploaded")}, status=404)
return FileResponse(
organization.logo.open("rb"),
content_type=organization.logo_content_type or "application/octet-stream",
@@ -111,7 +116,7 @@ class OrganizationLogoView(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("admin.choose_logo_file")}, status=400)
try:
organization = replace_organization_logo(
context=request.tenant_context,
@@ -244,8 +249,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),
},
+148 -135
View File
@@ -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,149 @@ 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_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 +212,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 +224,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 +233,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 +242,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
]
@@ -8,6 +8,7 @@ from chatballs.identity.auth.profile import (
ChangeTemporaryPasswordView,
ProfileAppearanceView,
ProfileAvatarView,
ProfileLanguageView,
ProfilePasswordView,
ProfileRevokeOtherSessionsView,
ProfileSessionsView,
@@ -28,6 +29,7 @@ __all__ = [
"ProfileUpdateView",
"ProfileAppearanceView",
"ProfileAvatarView",
"ProfileLanguageView",
"ProfilePasswordView",
"ProfileTotpStartView",
"ProfileTotpDisableView",
@@ -1,6 +1,7 @@
from django.conf import settings
from rest_framework.request import Request
from chatballs.i18n import current_language
from chatballs.identity.avatars import own_avatar_url
from chatballs.identity.models import HumanUser, Organization, OrganizationMembership
from chatballs.identity.policy import get_effective_access
@@ -62,6 +63,12 @@ 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),
"memberships": memberships,
}
@@ -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
@@ -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",
@@ -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 = ""
@@ -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
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)})
@@ -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"),
+2 -1
View File
@@ -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
@@ -65,7 +66,7 @@ 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 МБ"})
detected = image_type(data)
+49 -25
View File
@@ -1,9 +1,13 @@
from django.contrib.auth.tokens import default_token_generator
from django.core.mail import send_mail
from django.utils import translation
from django.utils.encoding import force_bytes
from django.utils.http import urlsafe_base64_encode
from chatballs.i18n import t
from chatballs.i18n.languages import resolve_language
from chatballs.identity.instance_settings import (
default_language,
email_connection,
email_from_address,
public_base_url,
@@ -19,34 +23,54 @@ def _password_setup_url(user: HumanUser) -> str:
return f"{public_base_url()}/reset-password?uid={uid}&token={token}"
def _recipient_language(user: HumanUser) -> str:
"""Язык получателя письма, а не того, кто нажал кнопку.
Приглашение сотруднику отправляет владелец, и язык запроса здесь не при
чём: письмо читает другой человек. Личный выбор получателя побеждает,
дальше — язык его организации, дальше — язык установки.
"""
membership = (
user.memberships.select_related("organization")
.filter(blocked_at__isnull=True)
.order_by("created_at", "id")
.first()
)
return resolve_language(
user_language=user.ui_language,
organization_language=membership.organization.language if membership else "",
instance_language=default_language(),
)
def send_initial_access_email(user: HumanUser) -> None:
setup_url = _password_setup_url(user)
send_mail(
subject="Первичный доступ к Chatballs",
message=(
f"Здравствуйте, {user.full_name or user.email}.\n\n"
"Для вас создана учётная запись Chatballs. Чтобы задать пароль первичного доступа, "
"перейдите по ссылке:\n"
f"{setup_url}\n\n"
"Ссылка действует 30 минут. Если вы не ожидали это письмо, обратитесь к владельцу организации."
),
from_email=email_from_address(),
recipient_list=[user.email],
connection=email_connection(),
)
with translation.override(_recipient_language(user)):
send_mail(
subject=t("emails.initial_access_subject"),
message=t(
"emails.initial_access_body",
name=user.full_name or user.email,
url=setup_url,
),
from_email=email_from_address(),
recipient_list=[user.email],
connection=email_connection(),
)
def send_password_reset_email(user: HumanUser) -> None:
reset_url = _password_setup_url(user)
send_mail(
subject="Восстановление доступа к Chatballs",
message=(
f"Здравствуйте, {user.full_name or user.email}.\n\n"
"Вы запросили сброс пароля для Chatballs. Чтобы задать новый пароль, перейдите по ссылке:\n"
f"{reset_url}\n\n"
"Ссылка действует 30 минут. Если вы не запрашивали сброс, просто проигнорируйте это письмо."
),
from_email=email_from_address(),
recipient_list=[user.email],
connection=email_connection(),
)
with translation.override(_recipient_language(user)):
send_mail(
subject=t("emails.password_reset_subject"),
message=t(
"emails.password_reset_body",
name=user.full_name or user.email,
url=reset_url,
),
from_email=email_from_address(),
recipient_list=[user.email],
connection=email_connection(),
)
@@ -4,6 +4,7 @@ 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.identity.audit import record_audit_event
from chatballs.identity.employee_password import clean_password_mode, reset_employee_password
from chatballs.identity.employee_support import employee_payload, get_owned_profile
@@ -36,12 +37,7 @@ class EmployeeResetPasswordView(APIView):
# организации, когда человек работает и в другой, нельзя.
if profile.user.memberships.count() > 1:
return Response(
{
"detail": (
"Сотрудник состоит в нескольких организациях — "
"пароль сбрасывает он сам через восстановление доступа"
)
},
{"detail": t("admin.multi_org_self_reset")},
status=409,
)
password = reset_employee_password(
@@ -7,6 +7,7 @@ from rest_framework.views import APIView
from chatballs.api.pagination import page_payload, paginate
from chatballs.events.services import DomainEvent, enqueue_event
from chatballs.i18n import t
from chatballs.identity.audit import record_audit_event
from chatballs.identity.employee_password import clean_password_mode, issue_initial_password
from chatballs.identity.employee_selectors import employees_for
@@ -50,7 +51,7 @@ class EmployeeAvatarView(APIView):
.first()
)
if membership is None or not membership.user.avatar:
return Response({"detail": "Фото не найдено"}, status=404)
return Response({"detail": t("admin.photo_not_found")}, status=404)
response = FileResponse(
membership.user.avatar.open("rb"),
content_type=membership.user.avatar_content_type or "application/octet-stream",
@@ -8,6 +8,7 @@ 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.audit import record_audit_event
from chatballs.identity.group_models import EmployeeGroup, EmployeeGroupMember
from chatballs.identity.models import OrganizationMembership
@@ -81,11 +82,11 @@ class GroupListView(APIView):
return Response({"detail": members_error}, status=400)
color = _clean_color(request.data.get("color"))
if color is None:
return Response({"detail": "Некорректный цвет"}, status=400)
return Response({"detail": t("conversations.invalid_colour")}, status=400)
try:
group = EmployeeGroup.objects.create(organization=organization, name=name, color=color)
except (ValidationError, IntegrityError):
return Response({"detail": "Группа с таким именем уже есть"}, status=400)
return Response({"detail": t("admin.group_name_taken")}, status=400)
if members:
EmployeeGroupMember.objects.bulk_create(
[
@@ -124,7 +125,7 @@ class GroupDetailView(APIView):
if "color" in request.data:
color = _clean_color(request.data.get("color"))
if color is None:
return Response({"detail": "Некорректный цвет"}, status=400)
return Response({"detail": t("conversations.invalid_colour")}, status=400)
group.color = color
if "name" in request.data or "color" in request.data:
if "name" in request.data:
@@ -132,7 +133,7 @@ class GroupDetailView(APIView):
try:
group.save()
except (ValidationError, IntegrityError):
return Response({"detail": "Группа с таким именем уже есть"}, status=400)
return Response({"detail": t("admin.group_name_taken")}, status=400)
members, members_error = _resolve_members(
group.organization, request.data.get("memberIds")
)
@@ -43,6 +43,11 @@ class InstanceSettings(models.Model):
email_use_tls = models.BooleanField(default=True)
email_from = models.CharField(max_length=255, blank=True, default="")
# Язык установки: на нём открываются экраны, где организации ещё нет, —
# логин, сброс пароля, мастер первого запуска, — и он же служит умолчанием
# для организаций, которые своего языка не выбрали.
default_language = models.CharField(max_length=5, blank=True, default="ru")
# Адреса TURN-серверов для звонков через relay. Секрет сюда не пишется:
# он общий с coturn и живёт в томе секретов, чтобы не вводить его дважды.
turn_urls = models.TextField(blank=True, default="")
@@ -107,6 +112,30 @@ def accepted_hosts() -> tuple[str, ...]:
return tuple(host for host in _hosts() if host)
def default_language() -> str:
"""Язык установки: экраны до входа и умолчание для организаций.
Читается без кэша, в отличие от адреса. Кэш с TTL здесь означал бы, что
один и тот же запрос стоит то одного запроса к базе, то нуля — в зависимости
от того, попал ли он в окно, — и язык установки некоторое время оставался бы
старым в тех процессах, которые смены не видели. Цена честности — один
поиск singleton-строки по первичному ключу, и только у запросов, которые
вообще досюда дошли: у сотрудника со своим языком и у организации со своим
до этой ветки дело не доходит.
Пустая строка означает установку, где язык ещё ни разу не задавали, — и
приводит к языку по умолчанию из ``chatballs.i18n``.
"""
from chatballs.i18n.languages import normalize_language
try:
row = InstanceSettings.objects.filter(pk=InstanceSettings.SINGLETON_PK).first()
except Exception: # таблицы ещё нет (первые миграции)
return ""
return normalize_language(row.default_language) if row is not None else ""
def remember_public_host(raw_host: str, scheme: str = "http") -> None:
"""Запомнить адрес, на котором прошли мастер, если он ещё не задан."""
@@ -13,6 +13,8 @@ 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.i18n.languages import DEFAULT_LANGUAGE, LANGUAGES, normalize_language
from chatballs.identity.instance_settings import (
InstanceSettings,
email_connection,
@@ -30,6 +32,9 @@ def instance_payload(row: InstanceSettings) -> dict:
"publicHost": row.public_host,
"publicScheme": row.public_scheme or "http",
"publicUrl": public_base_url(),
# Язык экранов, где организации ещё нет: логин, сброс пароля, мастер.
"defaultLanguage": row.default_language or DEFAULT_LANGUAGE,
"languages": [{"code": code, "label": label} for code, label in LANGUAGES],
"updatedAt": row.updated_at,
"email": {
"host": row.email_host,
@@ -70,31 +75,43 @@ class InstanceAddressView(APIView):
raw_host = raw_host.split("//", 1)[1]
host = normalize_domain(raw_host.split("/", 1)[0].split(":", 1)[0])
if not host:
errors["publicHost"] = "Укажите адрес установки"
errors["publicHost"] = t("settings.address_required")
else:
try:
# IP-адрес — обычный случай коробки: домена может не быть вовсе.
if not _looks_like_ipv4(host):
validate_domain(host)
except ValidationError:
errors["publicHost"] = "Некорректный адрес"
errors["publicHost"] = t("settings.invalid_address")
scheme = str(body.get("publicScheme", row.public_scheme or "http")).lower()
if scheme not in SCHEMES:
errors["publicScheme"] = "http или https"
errors["publicScheme"] = t("settings.http_or_https")
raw_language = str(body.get("defaultLanguage", row.default_language)).strip()
language = normalize_language(raw_language)
if raw_language and not language:
errors["defaultLanguage"] = t("settings.language_unsupported")
if errors:
return Response(
{"detail": next(iter(errors.values())), "errors": errors}, status=400
)
fields = ["public_host", "public_scheme", "previous_public_host", "updated_at"]
fields = [
"public_host",
"public_scheme",
"previous_public_host",
"default_language",
"updated_at",
]
if host != row.public_host:
# Прежний адрес остаётся принятым: владелец меняет адрес заранее,
# сидя на старом, и не должен выпасть из установки в тот же миг.
row.previous_public_host = row.public_host
row.public_host = host
row.public_scheme = scheme
row.default_language = language or DEFAULT_LANGUAGE
email = body.get("email")
if isinstance(email, dict):
@@ -106,7 +123,7 @@ class InstanceAddressView(APIView):
row.email_port = int(email.get("port", row.email_port))
except (TypeError, ValueError):
return Response(
{"detail": "Порт — число", "errors": {"emailPort": "Порт — число"}},
{"detail": t("settings.port_is_number"), "errors": {"emailPort": t("settings.port_is_number")}},
status=400,
)
# Пустой пароль означает «оставить прежний»: наружу он не отдаётся.
@@ -131,7 +148,7 @@ class InstanceAddressView(APIView):
row.turn_ttl_seconds = max(60, int(turn.get("ttlSeconds", row.turn_ttl_seconds)))
except (TypeError, ValueError):
return Response(
{"detail": "Время жизни — число секунд",
{"detail": t("settings.ttl_is_seconds"),
"errors": {"turnTtlSeconds": "Число секунд"}},
status=400,
)
@@ -163,7 +180,7 @@ class InstanceEmailCheckView(APIView):
connection = email_connection()
if connection is None:
return Response(
{"detail": "Сначала укажите сервер исходящей почты"}, status=400
{"detail": t("settings.smtp_required_first")}, status=400
)
recipient = str(request.data.get("email", "")).strip() or request.user.email
try:
@@ -0,0 +1,28 @@
# Generated by Django 5.2.17 on 2026-09-09 18:24
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('identity', '0034_encrypted_column_widths'),
]
operations = [
migrations.AddField(
model_name='humanuser',
name='ui_language',
field=models.CharField(blank=True, default='', max_length=5),
),
migrations.AddField(
model_name='instancesettings',
name='default_language',
field=models.CharField(blank=True, default='ru', max_length=5),
),
migrations.AddField(
model_name='organization',
name='language',
field=models.CharField(blank=True, default='', max_length=5),
),
]
+11
View File
@@ -78,6 +78,12 @@ class HumanUser(AbstractUser):
# тема и акцентный HEX-цвет; пустой акцент — дефолтный синий #1677ff.
ui_theme = models.CharField(max_length=8, choices=UiTheme.choices, default=UiTheme.SYSTEM)
ui_accent = models.CharField(max_length=9, blank=True)
# Язык интерфейса — тоже глобальная настройка пользователя, а не
# membership: человек читает на одном языке во всех организациях, куда
# входит. Пустая строка — «как в организации»: это не английский и не
# русский, а отсутствие личного выбора, и она переживает смену языка
# организации, тогда как записанный при регистрации код — нет.
ui_language = models.CharField(max_length=5, blank=True, default="")
# Фото сотрудника (дизайн-базлайн v2): видно коллегам в сайдбаре, подписи
# сообщений, выборе ответственного. Загружается в профиле.
avatar = models.FileField(upload_to=user_avatar_upload_path, storage=user_storage, max_length=512, blank=True, default="")
@@ -115,6 +121,11 @@ class Organization(models.Model):
)
timezone = models.CharField(max_length=64, default="Europe/Moscow")
currency = models.CharField(max_length=3, default="RUB")
# Язык рабочего места по умолчанию: на нём организация открывается всем,
# кто не выбрал свой в профиле. Стоит рядом с часовым поясом и валютой —
# это такой же региональный параметр организации, и в «Настройках» они
# лежат в одном разделе.
language = models.CharField(max_length=5, blank=True, default="")
logo = models.FileField(
upload_to=organization_logo_upload_path,
max_length=512,
@@ -2,6 +2,8 @@ import re
from django.core.exceptions import ValidationError
from chatballs.i18n import t
# Единые правила сложности пароля, зеркалят клиентскую проверку internal-ui.
_LETTER = re.compile(r"[A-Za-zА-Яа-я]")
_DIGIT = re.compile(r"\d")
@@ -13,11 +15,11 @@ class PasswordComplexityValidator:
def validate(self, password: str, user: object = None) -> None:
if not _LETTER.search(password):
raise ValidationError("Пароль должен содержать букву.", code="password_no_letter")
raise ValidationError(t("identity.password_needs_letter"), code="password_no_letter")
if not _DIGIT.search(password):
raise ValidationError("Пароль должен содержать цифру.", code="password_no_digit")
raise ValidationError(t("identity.password_needs_digit"), code="password_no_digit")
if not _SPECIAL.search(password):
raise ValidationError("Пароль должен содержать спецсимвол.", code="password_no_special")
raise ValidationError(t("identity.password_needs_special"), code="password_no_special")
def get_help_text(self) -> str:
return "Пароль должен содержать букву, цифру и спецсимвол."
+2 -1
View File
@@ -22,6 +22,7 @@ from django.db import connections, transaction
from django.utils.text import slugify
from chatballs.ai.knowledge_categories import ensure_uncategorized_category
from chatballs.i18n import t
from chatballs.identity.audit import record_audit_event
from chatballs.identity.instance_settings import remember_public_host
from chatballs.identity.models import (
@@ -76,7 +77,7 @@ def _clean(data: SetupInput) -> SetupInput:
errors: dict[str, str] = {}
organization_name = data.organization_name.strip()
if not organization_name:
errors["organizationName"] = "Укажите название организации"
errors["organizationName"] = t("admin.organization_name_required")
elif len(organization_name) > ORGANIZATION_NAME_MAX_LENGTH:
errors["organizationName"] = "Название длиннее 255 символов"
full_name = " ".join(data.full_name.split())
@@ -8,6 +8,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.auth.common import _user_payload
from chatballs.identity.setup import (
SetupAlreadyCompleted,
@@ -16,7 +17,10 @@ from chatballs.identity.setup import (
instance_needs_setup,
)
SETUP_CLOSED = {"detail": "Первый запуск уже выполнен: организация создана"}
# Функция, а не константа: язык запроса решается на каждый запрос заново.
def setup_closed() -> dict[str, str]:
return {"detail": t("identity.setup_already_done")}
def _validation_response(error: ValidationError) -> Response:
@@ -54,7 +58,7 @@ class SetupView(APIView):
def post(self, request: Request) -> Response:
body = request.data if isinstance(request.data, dict) else {}
if not instance_needs_setup():
return Response(SETUP_CLOSED, status=409)
return Response(setup_closed(), status=409)
try:
result = complete_setup(
SetupInput(
@@ -68,7 +72,7 @@ class SetupView(APIView):
public_scheme=request.scheme,
)
except SetupAlreadyCompleted:
return Response(SETUP_CLOSED, status=409)
return Response(setup_closed(), status=409)
except ValidationError as error:
return _validation_response(error)
owner = result.owner
@@ -61,6 +61,8 @@ class AdministrationApiTests(TestCase):
"name": "Example",
"timezone": "Europe/Moscow",
"currency": "RUB",
# Пустой язык — «как в установке»: организация своего не выбрала.
"language": "",
"logoUrl": None,
# Подпись «Сохранено …» у кнопки (кадр N1): пока правок не было — null.
"updatedAt": None,
+39
View File
@@ -325,6 +325,45 @@ class AuthEndpointTests(TestCase):
)
self.assertEqual(rejected.status_code, 400)
def test_profile_language_saves_personal_choice(self) -> None:
"""Язык — личная настройка; пустое значение возвращает к организации."""
self.client.login(username="owner@example.com", password="temporary-password")
response = self.client.post(
"/api/v1/auth/profile/language/",
data=json.dumps({"language": "en"}),
content_type="application/json",
)
self.assertEqual(response.status_code, 200)
payload = response.json()["user"]
self.assertEqual(payload["uiLanguage"], "en")
# Ответ уже на новом языке: интерфейс перерисуется по нему сразу.
self.assertEqual(payload["language"], "en")
owner = HumanUser.objects.get(email="owner@example.com")
self.assertEqual(owner.ui_language, "en")
self.assertTrue(
AuditEvent.objects.filter(action="identity.profile_language_changed").exists()
)
cleared = self.client.post(
"/api/v1/auth/profile/language/",
data=json.dumps({"language": ""}),
content_type="application/json",
)
self.assertEqual(cleared.status_code, 200)
# Пустая строка — не язык, а снятие личного выбора: человек снова едет
# за организацией.
self.assertEqual(cleared.json()["user"]["uiLanguage"], "")
rejected = self.client.post(
"/api/v1/auth/profile/language/",
data=json.dumps({"language": "de"}),
content_type="application/json",
)
self.assertEqual(rejected.status_code, 400)
def test_profile_password_changes_current_user_password(self) -> None:
self.client.login(username="owner@example.com", password="temporary-password")
@@ -11,6 +11,7 @@ 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.audit import record_audit_event
from chatballs.integrations.features import supports_calls
from chatballs.integrations.models import Integration, IntegrationKind
@@ -50,7 +51,7 @@ class CommunicationSettingsView(APIView):
organization = request.tenant_context.organization
items = request.data.get("items")
if not isinstance(items, list):
return Response({"detail": "Ожидается список items"}, status=400)
return Response({"detail": t("settings.items_list_expected")}, status=400)
by_id = {i.id: i for i in _entry_points(organization)}
changed: list[dict[str, object]] = []
for raw in items:
@@ -58,7 +59,7 @@ class CommunicationSettingsView(APIView):
continue
integration = by_id.get(raw.get("id"))
if integration is None:
return Response({"detail": "Точка входа не найдена"}, status=404)
return Response({"detail": t("settings.entry_point_not_found")}, status=404)
fields: list[str] = []
for key, attr in (("voiceMessages", "voice_messages_enabled"), ("audioCalls", "audio_calls_enabled"), ("videoCalls", "video_calls_enabled")):
if key in raw:
+2 -1
View File
@@ -18,6 +18,7 @@ import ssl
import urllib.parse
import urllib.request
from chatballs.i18n import t
from chatballs.integrations.outbound import OutboundUrlRejected
SOCKS_SCHEMES = ("socks5", "socks5h")
@@ -158,7 +159,7 @@ def _import_socks():
return socks
except ImportError as error:
raise ValueError("Для SOCKS-прокси нужна зависимость PySocks (pip install PySocks)") from error
raise ValueError(t("settings.socks_needs_pysocks")) from error
class _SocksHTTPConnection(http.client.HTTPConnection):
+4 -3
View File
@@ -5,6 +5,7 @@ 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.audit import record_audit_event
from chatballs.integrations.models import Integration
from chatballs.integrations.selectors import (
@@ -115,7 +116,7 @@ class IntegrationDetailView(APIView):
data=_input(request.data, current=integration),
)
except Integration.DoesNotExist:
return Response({"detail": "Интеграция не найдена"}, status=404)
return Response({"detail": t("settings.integration_not_found")}, status=404)
except (ValidationError, IntegrityError) as error:
return _validation_error(error)
_audit(request, "integrations.integration_updated", integration)
@@ -125,7 +126,7 @@ class IntegrationDetailView(APIView):
try:
integration = self._get(request, integration_id)
except Integration.DoesNotExist:
return Response({"detail": "Интеграция не найдена"}, status=404)
return Response({"detail": t("settings.integration_not_found")}, status=404)
_audit(request, "integrations.integration_deleted", integration)
delete_integration(context=request.tenant_context, integration=integration)
return Response(status=204)
@@ -141,7 +142,7 @@ class IntegrationTestView(APIView):
context=request.tenant_context, integration_id=integration_id
)
except Integration.DoesNotExist:
return Response({"detail": "Интеграция не найдена"}, status=404)
return Response({"detail": t("settings.integration_not_found")}, status=404)
integration = test_integration(
context=request.tenant_context, integration=integration
)
@@ -0,0 +1,28 @@
# Generated by Django 5.2.17 on 2026-09-09 19:15
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('notifications', '0009_alter_notification_type'),
]
operations = [
migrations.AddField(
model_name='notification',
name='body_key',
field=models.CharField(blank=True, default='', max_length=64),
),
migrations.AddField(
model_name='notification',
name='text_params',
field=models.JSONField(blank=True, default=dict),
),
migrations.AddField(
model_name='notification',
name='title_key',
field=models.CharField(blank=True, default='', max_length=64),
),
]
@@ -33,8 +33,16 @@ class Notification(models.Model):
organization = models.ForeignKey("identity.Organization", on_delete=models.PROTECT, related_name="notifications")
type = models.CharField(max_length=32, choices=NotificationType.choices)
level = models.CharField(max_length=16, choices=NotificationLevel.choices, default=NotificationLevel.INFO)
# Текст на языке того, кто писал код: остаётся и для записей, сделанных
# до появления ключей, и для доставки в мессенджер, где рендерить нечем.
title = models.CharField(max_length=255)
body = models.TextField(blank=True)
# Ключи каталога и их параметры. Уведомление адресовано аудитории
# («операторам»), а не одному человеку, и разные операторы могут читать на
# разных языках — поэтому фраза собирается при показе, а не при записи.
title_key = models.CharField(max_length=64, blank=True, default="")
body_key = models.CharField(max_length=64, blank=True, default="")
text_params = models.JSONField(default=dict, blank=True)
# Диплинк по клику.
target_route = models.CharField(max_length=64, blank=True)
target_id = models.CharField(max_length=64, blank=True)
@@ -1,13 +1,27 @@
from chatballs.i18n import t
from chatballs.notifications.models import Notification
def _text(key: str, stored: str, params: dict) -> str:
"""Фраза на языке запроса, если у записи есть ключ каталога.
Уведомление адресовано аудитории, а не одному человеку: два оператора
одной организации могут читать на разных языках, и записать готовую фразу
в момент события нельзя. Записи, сделанные до появления ключей, приходят
сохранённым текстом — переписывать историю задним числом не за что.
"""
return t(key, **params) if key else stored
def notification_payload(notification: Notification, *, unread: bool) -> dict[str, object]:
params = notification.text_params or {}
return {
"id": notification.id,
"type": notification.type,
"level": notification.level,
"title": notification.title,
"body": notification.body,
"title": _text(notification.title_key, notification.title, params),
"body": _text(notification.body_key, notification.body, params),
"targetRoute": notification.target_route,
"targetId": notification.target_id,
"createdAt": notification.created_at.isoformat(),
@@ -31,6 +31,9 @@ def notify(
audience: str,
title: str,
body: str = "",
title_key: str = "",
body_key: str = "",
text_params: dict | None = None,
target_id: object = "",
recipient_user=None,
source_type: str = "",
@@ -51,6 +54,9 @@ def notify(
audience=audience,
title=title,
body=body,
title_key=title_key,
body_key=body_key,
text_params=text_params or {},
level=level or meta.get("level", NotificationLevel.INFO),
target_route=target_route,
target_id=str(target_id) if target_id else "",
@@ -3,6 +3,7 @@ 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.notifications.binding import deep_link, issue_binding_code, notifier_integrations
from chatballs.notifications.models import MessengerBinding, NotificationRead, NotificationType
from chatballs.notifications.selectors import unread_for, visible_for
@@ -69,7 +70,7 @@ class MessengerBindingDetailView(APIView):
"""Выдать одноразовый код привязки и deep-link на бота."""
integration = self._integration(request, integration_id)
if integration is None:
return Response({"detail": "Бот уведомлений не найден"}, status=404)
return Response({"detail": t("profile.notification_bot_not_found")}, status=404)
binding_code = issue_binding_code(
context=request.tenant_context, integration=integration
)
@@ -86,7 +87,7 @@ class MessengerBindingDetailView(APIView):
"""Обновить типы уведомлений, доставляемые в мессенджер."""
binding = MessengerBinding.objects.filter(user=request.user, integration_id=integration_id).first()
if binding is None:
return Response({"detail": "Привязка не найдена"}, status=404)
return Response({"detail": t("profile.link_not_found")}, status=404)
types = request.data.get("pushTypes")
if not isinstance(types, list):
return Response({"detail": "pushTypes must be a list"}, status=400)
@@ -6,6 +6,8 @@ from django.conf import settings
from django.core.exceptions import ValidationError
from django.db import models
from chatballs.i18n import t
HOST_LABEL_RE = re.compile(r"^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$")
@@ -16,10 +18,10 @@ def normalize_domain(value: str) -> str:
def validate_domain(value: str) -> str:
domain = normalize_domain(value)
if not domain or len(domain) > 253:
raise ValidationError("Некорректное доменное имя")
raise ValidationError(t("portals.invalid_domain"))
labels = domain.split(".")
if len(labels) < 2 or any(not HOST_LABEL_RE.fullmatch(label) for label in labels):
raise ValidationError("Некорректное доменное имя")
raise ValidationError(t("portals.invalid_domain"))
return domain
@@ -1,6 +1,7 @@
from rest_framework.request import Request
from rest_framework.response import Response
from chatballs.i18n import t
from chatballs.identity.audit import record_audit_event
from chatballs.support_portals.article_import import import_articles
from chatballs.support_portals.portal_views import PortalBaseView
@@ -10,7 +11,7 @@ class ArticleImportView(PortalBaseView):
def post(self, request: Request, portal_id: int) -> Response:
portal = self.portal(request, portal_id)
if portal is None:
return Response({"detail": "Портал не найден"}, status=404)
return Response({"detail": t("portals.not_found")}, status=404)
articles = request.data.get("articles")
if not isinstance(articles, list) or not articles:
return Response({"detail": "articles must be a non-empty list"}, status=400)
@@ -8,6 +8,7 @@ from django.utils.text import slugify
from chatballs.ai.indexing import reindex_portal_article
from chatballs.conversations.transports.base import guess_content_type, safe_filename
from chatballs.i18n import t
from chatballs.support_portals.content_markdown import normalize_file_links
from chatballs.support_portals.models import (
PortalArticle,
@@ -193,7 +194,7 @@ def _category(portal: SupportPortal, category_id) -> PortalCategory:
try:
return portal.categories.get(id=int(category_id))
except (PortalCategory.DoesNotExist, TypeError, ValueError) as error:
raise ValidationError({"categoryId": "Раздел не найден"}) from error
raise ValidationError({"categoryId": t("portals.section_not_found")}) from error
def ensure_portal_editable(portal: SupportPortal) -> None:
@@ -4,6 +4,7 @@ from rest_framework.request import Request
from rest_framework.response import Response
from chatballs.api.pagination import page_payload, paginate
from chatballs.i18n import t
from chatballs.support_portals.api import validation_response
from chatballs.support_portals.content_services import (
add_article_file,
@@ -47,7 +48,7 @@ class CategoryListView(PortalBaseView):
def get(self, request: Request, portal_id: int) -> Response:
portal = self.portal(request, portal_id)
if portal is None:
return Response({"detail": "Портал не найден"}, status=404)
return Response({"detail": t("portals.not_found")}, status=404)
counts = category_article_counts(portal)
return Response(
{
@@ -61,7 +62,7 @@ class CategoryListView(PortalBaseView):
def post(self, request: Request, portal_id: int) -> Response:
portal = self.portal(request, portal_id)
if portal is None:
return Response({"detail": "Портал не найден"}, status=404)
return Response({"detail": t("portals.not_found")}, status=404)
try:
category = create_category(
context=request.tenant_context,
@@ -71,7 +72,7 @@ class CategoryListView(PortalBaseView):
except (ValidationError, TypeError, ValueError) as error:
if isinstance(error, ValidationError):
return validation_response(error)
return Response({"detail": "Некорректная категория"}, status=400)
return Response({"detail": t("portals.invalid_category")}, status=400)
return Response({"category": category_payload(category)}, status=201)
@@ -87,9 +88,9 @@ class CategoryDetailView(PortalBaseView):
) -> Response:
portal, category = self._category(request, portal_id, category_id)
if portal is None:
return Response({"detail": "Портал не найден"}, status=404)
return Response({"detail": t("portals.not_found")}, status=404)
if category is None:
return Response({"detail": "Раздел не найден"}, status=404)
return Response({"detail": t("portals.section_not_found")}, status=404)
try:
category = update_category(
portal=portal, category=category, data=request.data
@@ -97,7 +98,7 @@ class CategoryDetailView(PortalBaseView):
except (ValidationError, TypeError, ValueError) as error:
if isinstance(error, ValidationError):
return validation_response(error)
return Response({"detail": "Некорректный раздел"}, status=400)
return Response({"detail": t("portals.invalid_section")}, status=400)
return Response({"category": category_payload(category)})
def delete(
@@ -105,9 +106,9 @@ class CategoryDetailView(PortalBaseView):
) -> Response:
portal, category = self._category(request, portal_id, category_id)
if portal is None:
return Response({"detail": "Портал не найден"}, status=404)
return Response({"detail": t("portals.not_found")}, status=404)
if category is None:
return Response({"detail": "Раздел не найден"}, status=404)
return Response({"detail": t("portals.section_not_found")}, status=404)
try:
delete_category(portal=portal, category=category)
except ValidationError as error:
@@ -119,7 +120,7 @@ class ArticleListView(PortalBaseView):
def get(self, request: Request, portal_id: int) -> Response:
portal = self.portal(request, portal_id)
if portal is None:
return Response({"detail": "Портал не найден"}, status=404)
return Response({"detail": t("portals.not_found")}, status=404)
"""Страница библиотеки: категория, язык, статус и поиск — на сервере."""
page = paginate(
portal_articles_queryset(portal, request.query_params),
@@ -131,7 +132,7 @@ class ArticleListView(PortalBaseView):
def post(self, request: Request, portal_id: int) -> Response:
portal = self.portal(request, portal_id)
if portal is None:
return Response({"detail": "Портал не найден"}, status=404)
return Response({"detail": t("portals.not_found")}, status=404)
try:
article = create_article(
context=request.tenant_context,
@@ -141,7 +142,7 @@ class ArticleListView(PortalBaseView):
except (ValidationError, TypeError, ValueError) as error:
if isinstance(error, ValidationError):
return validation_response(error)
return Response({"detail": "Некорректная статья"}, status=400)
return Response({"detail": t("portals.invalid_article")}, status=400)
article = _article(portal, article.id)
return Response(
{"article": article_payload(article, revisions=True, files=True)},
@@ -153,25 +154,25 @@ class ArticleDetailView(PortalBaseView):
def get(self, request: Request, portal_id: int, article_id: int) -> Response:
portal = self.portal(request, portal_id)
if portal is None:
return Response({"detail": "Портал не найден"}, status=404)
return Response({"detail": t("portals.not_found")}, status=404)
article = _article(portal, article_id)
if article is None:
return Response({"detail": "Статья не найдена"}, status=404)
return Response({"detail": t("portals.article_not_found")}, status=404)
return Response({"article": article_payload(article, revisions=True, files=True)})
def patch(self, request: Request, portal_id: int, article_id: int) -> Response:
portal = self.portal(request, portal_id)
if portal is None:
return Response({"detail": "Портал не найден"}, status=404)
return Response({"detail": t("portals.not_found")}, status=404)
article = _article(portal, article_id)
if article is None:
return Response({"detail": "Статья не найдена"}, status=404)
return Response({"detail": t("portals.article_not_found")}, status=404)
try:
article = update_article(article=article, data=request.data)
except (ValidationError, TypeError, ValueError) as error:
if isinstance(error, ValidationError):
return validation_response(error)
return Response({"detail": "Некорректные данные статьи"}, status=400)
return Response({"detail": t("portals.invalid_article_data")}, status=400)
article = _article(portal, article.id)
return Response({"article": article_payload(article, revisions=True, files=True)})
@@ -180,10 +181,10 @@ class ArticleRevisionListView(PortalBaseView):
def post(self, request: Request, portal_id: int, article_id: int) -> Response:
portal = self.portal(request, portal_id)
if portal is None:
return Response({"detail": "Портал не найден"}, status=404)
return Response({"detail": t("portals.not_found")}, status=404)
article = _article(portal, article_id)
if article is None:
return Response({"detail": "Статья не найдена"}, status=404)
return Response({"detail": t("portals.article_not_found")}, status=404)
try:
revision = add_revision(
context=request.tenant_context,
@@ -200,10 +201,10 @@ class ArticlePublishView(PortalBaseView):
def post(self, request: Request, portal_id: int, article_id: int) -> Response:
portal = self.portal(request, portal_id)
if portal is None:
return Response({"detail": "Портал не найден"}, status=404)
return Response({"detail": t("portals.not_found")}, status=404)
article = _article(portal, article_id)
if article is None:
return Response({"detail": "Статья не найдена"}, status=404)
return Response({"detail": t("portals.article_not_found")}, status=404)
try:
article = publish_revision(
article=article,
@@ -212,7 +213,7 @@ class ArticlePublishView(PortalBaseView):
except (ValidationError, TypeError, ValueError) as error:
if isinstance(error, ValidationError):
return validation_response(error)
return Response({"detail": "Некорректная версия"}, status=400)
return Response({"detail": t("portals.invalid_revision")}, status=400)
article = _article(portal, article.id)
return Response({"article": article_payload(article, revisions=True, files=True)})
@@ -221,10 +222,10 @@ class ArticleArchiveView(PortalBaseView):
def post(self, request: Request, portal_id: int, article_id: int) -> Response:
portal = self.portal(request, portal_id)
if portal is None:
return Response({"detail": "Портал не найден"}, status=404)
return Response({"detail": t("portals.not_found")}, status=404)
article = _article(portal, article_id)
if article is None:
return Response({"detail": "Статья не найдена"}, status=404)
return Response({"detail": t("portals.article_not_found")}, status=404)
article = archive_article(article)
return Response({"article": article_payload(article, revisions=True, files=True)})
@@ -237,10 +238,10 @@ class ArticleFileListView(PortalBaseView):
def get(self, request: Request, portal_id: int, article_id: int) -> Response:
portal = self.portal(request, portal_id)
if portal is None:
return Response({"detail": "Портал не найден"}, status=404)
return Response({"detail": t("portals.not_found")}, status=404)
article = _article(portal, article_id)
if article is None:
return Response({"detail": "Статья не найдена"}, status=404)
return Response({"detail": t("portals.article_not_found")}, status=404)
return Response(
{"items": [article_file_payload(item) for item in article.files.all()]}
)
@@ -248,14 +249,14 @@ class ArticleFileListView(PortalBaseView):
def post(self, request: Request, portal_id: int, article_id: int) -> Response:
portal = self.portal(request, portal_id)
if portal is None:
return Response({"detail": "Портал не найден"}, status=404)
return Response({"detail": t("portals.not_found")}, status=404)
article = _article(portal, article_id)
if article is None:
return Response({"detail": "Статья не найдена"}, status=404)
return Response({"detail": t("portals.article_not_found")}, status=404)
upload = request.FILES.get("file")
if upload is None:
return Response(
{"detail": "Нужен файл в поле file (multipart/form-data)"}, status=400
{"detail": t("portals.file_field_required")}, status=400
)
try:
article_file = add_article_file(
@@ -275,13 +276,13 @@ class ArticleFileDetailView(PortalBaseView):
) -> Response:
portal = self.portal(request, portal_id)
if portal is None:
return Response({"detail": "Портал не найден"}, status=404)
return Response({"detail": t("portals.not_found")}, status=404)
article = _article(portal, article_id)
if article is None:
return Response({"detail": "Статья не найдена"}, status=404)
return Response({"detail": t("portals.article_not_found")}, status=404)
article_file = article.files.filter(id=file_id).first()
if article_file is None:
return Response({"detail": "Файл не найден"}, status=404)
return Response({"detail": t("portals.file_not_found")}, status=404)
try:
delete_article_file(
context=request.tenant_context, article_file=article_file
@@ -6,6 +6,7 @@ from rest_framework.views import APIView
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.integrations.models import IntegrationProvider, IntegrationStatus
from chatballs.support_portals.api import validation_response
@@ -173,14 +174,14 @@ class PortalDetailView(PortalBaseView):
def get(self, request: Request, portal_id: int) -> Response:
portal = self.portal(request, portal_id)
if portal is None:
return Response({"detail": "Портал не найден"}, status=404)
return Response({"detail": t("portals.not_found")}, status=404)
counts = portal_content_counts(request.tenant_context).get(portal.id, {})
return Response({"portal": portal_payload(portal, counts=counts)})
def patch(self, request: Request, portal_id: int) -> Response:
portal = self.portal(request, portal_id)
if portal is None:
return Response({"detail": "Портал не найден"}, status=404)
return Response({"detail": t("portals.not_found")}, status=404)
try:
update_portal(
context=request.tenant_context,
@@ -197,7 +198,7 @@ class PortalStatusView(PortalBaseView):
def post(self, request: Request, portal_id: int) -> Response:
portal = self.portal(request, portal_id)
if portal is None:
return Response({"detail": "Портал не найден"}, status=404)
return Response({"detail": t("portals.not_found")}, status=404)
try:
portal = set_portal_status(
context=request.tenant_context,
@@ -224,7 +225,7 @@ class PortalWidgetOptionsView(PortalBaseView):
def get(self, request: Request, portal_id: int) -> Response:
portal = self.portal(request, portal_id)
if portal is None:
return Response({"detail": "Портал не найден"}, status=404)
return Response({"detail": t("portals.not_found")}, status=404)
widgets = (
WebChatWidget.objects.select_related(
"integration",
@@ -248,7 +249,7 @@ class PortalDomainView(PortalBaseView):
def put(self, request: Request, portal_id: int) -> Response:
portal = self.portal(request, portal_id)
if portal is None:
return Response({"detail": "Портал не найден"}, status=404)
return Response({"detail": t("portals.not_found")}, status=404)
try:
portal = set_custom_domain(
portal, str(request.data.get("customDomain", ""))
@@ -263,7 +264,7 @@ class PortalDomainVerifyView(PortalBaseView):
def post(self, request: Request, portal_id: int) -> Response:
portal = self.portal(request, portal_id)
if portal is None:
return Response({"detail": "Портал не найден"}, status=404)
return Response({"detail": t("portals.not_found")}, status=404)
try:
portal = verify_custom_domain(portal)
except ValidationError as error:
@@ -4,6 +4,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.models import Organization
from chatballs.support_portals.content_services import record_feedback
from chatballs.support_portals.models import SupportPortal
@@ -55,7 +56,7 @@ class PublicPortalDetailView(PublicPortalView):
def get(self, request: Request) -> Response:
resolved = self.resolve(request)
if resolved is None:
return Response({"detail": "Портал не найден"}, status=404)
return Response({"detail": t("portals.not_found")}, status=404)
context, portal = resolved
with tenant_atomic(context):
counts = category_article_counts(portal, published_only=True)
@@ -76,7 +77,7 @@ class PublicArticleListView(PublicPortalView):
def get(self, request: Request) -> Response:
resolved = self.resolve(request)
if resolved is None:
return Response({"detail": "Портал не найден"}, status=404)
return Response({"detail": t("portals.not_found")}, status=404)
context, portal = resolved
locale = str(request.query_params.get("locale", portal.default_locale))
with tenant_atomic(context):
@@ -90,7 +91,7 @@ class PublicArticleListView(PublicPortalView):
limit = min(max(int(request.query_params.get("limit", 50)), 1), 100)
offset = max(int(request.query_params.get("offset", 0)), 0)
except (TypeError, ValueError):
return Response({"detail": "Некорректная пагинация"}, status=400)
return Response({"detail": t("portals.invalid_pagination")}, status=400)
total = articles.count()
page = articles[offset : offset + limit]
return Response(
@@ -113,7 +114,7 @@ class PublicArticleDetailView(PublicPortalView):
def get(self, request: Request, article_slug: str) -> Response:
resolved = self.resolve(request)
if resolved is None:
return Response({"detail": "Портал не найден"}, status=404)
return Response({"detail": t("portals.not_found")}, status=404)
context, portal = resolved
locale = str(request.query_params.get("locale", portal.default_locale))
with tenant_atomic(context):
@@ -121,7 +122,7 @@ class PublicArticleDetailView(PublicPortalView):
slug=article_slug
).first()
if article is None:
return Response({"detail": "Статья не найдена"}, status=404)
return Response({"detail": t("portals.article_not_found")}, status=404)
return Response({"article": public_article_payload(article)})
@@ -132,10 +133,10 @@ class PublicArticleFeedbackView(PublicPortalView):
def post(self, request: Request, article_slug: str) -> Response:
resolved = self.resolve(request)
if resolved is None:
return Response({"detail": "Портал не найден"}, status=404)
return Response({"detail": t("portals.not_found")}, status=404)
helpful = request.data.get("helpful")
if not isinstance(helpful, bool):
return Response({"detail": "helpful должен быть boolean"}, status=400)
return Response({"detail": t("portals.helpful_must_be_boolean")}, status=400)
context, portal = resolved
locale = str(request.query_params.get("locale", portal.default_locale))
with tenant_atomic(context):
@@ -143,6 +144,6 @@ class PublicArticleFeedbackView(PublicPortalView):
slug=article_slug
).first()
if article is None:
return Response({"detail": "Статья не найдена"}, status=404)
return Response({"detail": t("portals.article_not_found")}, status=404)
record_feedback(article, helpful)
return Response({"ok": True}, status=201)
@@ -16,6 +16,7 @@ from django.core.files import File
from django.utils import timezone
from chatballs.events.handlers import register
from chatballs.i18n import t
from chatballs.tenancy import storage_settings as ss
from chatballs.tenancy.context import TenantContext
@@ -38,7 +39,7 @@ def _local_keys(root: Path) -> list[str]:
def migrate_local_to_s3(row: ss.StorageSettings, *, batch_progress: int = 25) -> int:
config = ss.config_from_settings(row)
if not config.s3_configured:
raise ValueError("S3 не настроено")
raise ValueError(t("settings.s3_not_configured"))
s3 = ss.build_s3_storage(config)
root = Path(settings.MEDIA_ROOT)
keys = _local_keys(root)
@@ -17,6 +17,7 @@ from django.core.exceptions import ValidationError
from django.db import models
from django.utils import timezone
from chatballs.i18n import t
from chatballs.identity.crypto import EncryptedCharField
@@ -158,7 +159,7 @@ def build_s3_storage(config: StorageConfig, *, probe: bool = False):
from botocore.config import Config
from storages.backends.s3 import S3Storage
except ImportError as error: # pragma: no cover - deployment guard
raise ValidationError("django-storages[s3] не установлен") from error
raise ValidationError(t("settings.storages_missing")) from error
client_config = (
Config(connect_timeout=5, read_timeout=10, retries={"max_attempts": 1})
if probe
@@ -18,6 +18,7 @@ from rest_framework.views import APIView
from chatballs.api.permissions import HasCapability
from chatballs.events.services import DomainEvent, enqueue_event
from chatballs.i18n import t
from chatballs.identity.audit import record_audit_event
from chatballs.tenancy import storage_settings as ss
@@ -91,7 +92,7 @@ class StorageSettingsView(APIView):
body = request.data
backend = str(body.get("backend") or row.backend).upper()
if backend not in ss.StorageBackend.values:
return Response({"detail": "Неизвестный тип хранилища", "errors": {"backend": "LOCAL или S3"}}, status=400)
return Response({"detail": t("settings.unknown_storage_kind"), "errors": {"backend": "LOCAL или S3"}}, status=400)
config = _apply_fields(row, body)
if backend == ss.StorageBackend.S3:
try:
@@ -150,9 +151,9 @@ class StorageMigrateView(APIView):
def post(self, request: Request) -> Response:
row = ss.StorageSettings.load()
if row.backend != ss.StorageBackend.S3 or not row.s3_configured:
return Response({"detail": "Сначала включите и сохраните S3-хранилище"}, status=400)
return Response({"detail": t("settings.enable_s3_first")}, status=400)
if row.migration_status == ss.StorageMigrationStatus.RUNNING:
return Response({"detail": "Перенос уже идёт"}, status=409)
return Response({"detail": t("settings.migration_running")}, status=409)
row.migration_status = ss.StorageMigrationStatus.RUNNING
row.migration_total = 0
row.migration_done = 0
+13 -2
View File
@@ -18,6 +18,9 @@ from chatballs.conversations.models import (
MessageKind,
)
from chatballs.conversations.transports.base import InboundMessage
from chatballs.i18n import t
from chatballs.i18n.languages import resolve_language
from chatballs.identity.instance_settings import default_language
from chatballs.integrations.features import features_payload
from chatballs.integrations.models import Integration, IntegrationProvider
from chatballs.tenancy.context import TenantContext
@@ -92,12 +95,20 @@ def public_config(*, context: TenantContext, widget: WebChatWidget, origin: str)
for sib in Integration.objects.filter(channel=channel).exclude(id=integration.id):
username = sib.config.get("bot_username")
if sib.provider == IntegrationProvider.TELEGRAM and username:
fallback.append({"label": "Написать в Telegram", "url": f"https://t.me/{username}"})
fallback.append({"label": t("webchat.write_in_telegram"), "url": f"https://t.me/{username}"})
elif sib.provider == IntegrationProvider.MAX and username:
fallback.append({"label": "Написать в MAX", "url": ""})
fallback.append({"label": t("webchat.write_in_max"), "url": ""})
return {
"available": True,
"widgetKey": widget.public_key,
# Язык обвязки виджета — язык организации: на нём отвечают и агент, и
# оператор, и английская кнопка «Send» вокруг русских ответов выглядела
# бы ошибкой. Если организация языка не выбрала, берётся язык
# установки; пустое значение оставляет решение браузеру посетителя.
"language": resolve_language(
organization_language=context.organization.language,
instance_language=default_language(),
),
# Что разрешено в этой точке входа: виджет прячет микрофон при запрете.
"features": features_payload(integration),
"title": cfg.get("title") or channel.name,
+20 -19
View File
@@ -14,6 +14,7 @@ from chatballs.conversations.attachment_views import (
)
from chatballs.conversations.models import Message, MessageKind
from chatballs.conversations.voice_views import ALLOWED_AUDIO_TYPES, MAX_VOICE_BYTES
from chatballs.i18n import t
from chatballs.identity.models import Organization
from chatballs.integrations.features import voice_messages_allowed
from chatballs.integrations.models import IntegrationStatus
@@ -136,15 +137,15 @@ class WebchatSessionView(_Public):
channel_code = str(request.data.get("channel", ""))
with _resolved_web_widget(widget_key, channel_code) as (context, widget):
if context is None or widget is None:
return Response({"detail": "Виджет недоступен"}, status=404)
return Response({"detail": t("webchat.widget_unavailable")}, status=404)
if (
not widget.integration.channel.allow_anonymous_sessions
or not services.origin_allowed(widget, host_origin(request))
):
return Response({"detail": "Виджет недоступен"}, status=404)
return Response({"detail": t("webchat.widget_unavailable")}, status=404)
result = services.issue_session(context=context, widget=widget)
if result is None:
return Response({"detail": "Виджет недоступен"}, status=404)
return Response({"detail": t("webchat.widget_unavailable")}, status=404)
return Response(result, status=201)
@@ -155,17 +156,17 @@ class WebchatMessagesView(_PublicSession):
def post(self, request: Request) -> Response:
with _resolved_web_session(request) as (_context, session):
if session is None:
return Response({"detail": "Сессия не найдена"}, status=401)
return Response({"detail": t("webchat.session_not_found")}, status=401)
upload = request.FILES.get("audio")
if upload is not None:
# Голосовое из виджета (дизайн-базлайн v2, кадр H).
if not voice_messages_allowed(session.connection):
return Response({"detail": "Голосовые отключены"}, status=400)
return Response({"detail": t("webchat.voice_off")}, 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/webm").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):
@@ -192,14 +193,14 @@ class WebchatMessagesView(_PublicSession):
return Response({"ok": True}, status=201)
text = str(request.data.get("text", "")).strip()
if not text:
return Response({"detail": "Пустое сообщение"}, status=400)
return Response({"detail": t("ai.empty_message")}, status=400)
services.post_message(session, text[:4000])
return Response({"ok": True}, status=201)
def get(self, request: Request) -> Response:
with _resolved_web_session(request) as (_context, session):
if session is None:
return Response({"detail": "Сессия не найдена"}, status=401)
return Response({"detail": t("webchat.session_not_found")}, status=401)
try:
since = int(request.GET.get("since", "0") or 0)
except ValueError:
@@ -211,7 +212,7 @@ class WebchatMessageAudioView(_PublicSession):
def get(self, request: Request, message_id: int) -> Response | FileResponse:
with _resolved_web_session(request) as (_context, session):
if session is None:
return Response({"detail": "Сессия не найдена"}, status=401)
return Response({"detail": t("webchat.session_not_found")}, status=401)
message = (
Message.objects.filter(
id=message_id,
@@ -223,7 +224,7 @@ class WebchatMessageAudioView(_PublicSession):
.first()
)
if message is None:
return Response({"detail": "Сообщение не найдено"}, status=404)
return Response({"detail": t("conversations.message_not_found")}, status=404)
response = FileResponse(
message.audio.open("rb"),
content_type=message.audio_content_type or "audio/ogg",
@@ -236,7 +237,7 @@ class WebchatMessageAttachmentView(_PublicSession):
def get(self, request: Request, message_id: int) -> Response | FileResponse:
with _resolved_web_session(request) as (_context, session):
if session is None:
return Response({"detail": "Сессия не найдена"}, status=401)
return Response({"detail": t("webchat.session_not_found")}, status=401)
message = (
Message.objects.filter(
id=message_id,
@@ -248,7 +249,7 @@ class WebchatMessageAttachmentView(_PublicSession):
.first()
)
if message is None:
return Response({"detail": "Сообщение не найдено"}, status=404)
return Response({"detail": t("conversations.message_not_found")}, status=404)
response = attachment_response(message, inline="inline" in request.GET)
response["Cache-Control"] = "private, max-age=3600"
return response
@@ -258,10 +259,10 @@ class WebchatContactView(_PublicSession):
def post(self, request: Request) -> Response:
with _resolved_web_session(request) as (_context, session):
if session is None:
return Response({"detail": "Сессия не найдена"}, status=401)
return Response({"detail": t("webchat.session_not_found")}, status=401)
phone = services.normalize_phone(str(request.data.get("phone", "")))
if not phone:
return Response({"detail": "Некорректный номер телефона"}, status=400)
return Response({"detail": t("webchat.invalid_phone")}, status=400)
services.post_contact(session, phone)
return Response({"ok": True}, status=201)
@@ -274,11 +275,11 @@ class WebchatCallOpenView(_PublicSession):
with _resolved_web_session(request) as (_context, session):
if session is None:
return Response({"detail": "Сессия не найдена"}, status=401)
return Response({"detail": t("webchat.session_not_found")}, status=401)
try:
resolved = open_call_for_identity(identity=session.identity)
except CallTokenError:
return Response({"detail": "Активное приглашение не найдено"}, status=404)
return Response({"detail": t("webchat.no_active_invite")}, status=404)
response = Response(
{
"call": public_invite_payload(resolved.invite.call_session, resolved.invite.expires_at),
@@ -297,11 +298,11 @@ class WebchatCallDeclineView(_PublicSession):
with _resolved_web_session(request) as (_context, session):
if session is None:
return Response({"detail": "Сессия не найдена"}, status=401)
return Response({"detail": t("webchat.session_not_found")}, status=401)
try:
decline_call_for_identity(identity=session.identity)
except CallTokenError:
return Response({"detail": "Активное приглашение не найдено"}, status=404)
return Response({"detail": t("webchat.no_active_invite")}, status=404)
except CallConflict as error:
return Response({"detail": str(error)}, status=409)
return Response({"ok": True})
@@ -5,6 +5,8 @@ from pathlib import Path
from django.core.exceptions import ImproperlyConfigured
from chatballs.i18n.languages import DEFAULT_LANGUAGE
from chatballs.i18n.languages import LANGUAGES as CHATBALLS_LANGUAGES
from chatballs_backend.settings_database import build_databases
from chatballs_backend.settings_env import env_bool, env_list, env_secret
from chatballs_backend.settings_storage import build_storage_settings
@@ -85,6 +87,9 @@ MIDDLEWARE = [
"chatballs.identity.middleware.SessionActivityMiddleware",
"chatballs.events.middleware.CorrelationIdMiddleware",
"chatballs.tenancy.middleware.TenantContextMiddleware",
# Последней в цепочке — ближе всех к вьюхе: язык организации известен
# только после того, как TenantContextMiddleware её определил.
"chatballs.i18n.middleware.LanguageMiddleware",
]
TEMPLATES = [
@@ -141,7 +146,13 @@ AUTH_PASSWORD_VALIDATORS = [
{"NAME": "chatballs.identity.password_validation.PasswordComplexityValidator"},
]
LANGUAGE_CODE = "ru-ru"
# Язык — настройка продукта, а не окружения: его выбирает владелец в
# «Настройках» и каждый сотрудник в профиле, а LANGUAGE_CODE остаётся только
# последним рубежом для кода, который выполняется вне запроса (команды,
# фоновые задачи). Список языков общий с каталогом chatballs.i18n, чтобы
# добавление языка не требовало править настройки в двух местах.
LANGUAGE_CODE = DEFAULT_LANGUAGE
LANGUAGES = CHATBALLS_LANGUAGES
TIME_ZONE = "Europe/Moscow"
USE_I18N = True
USE_TZ = True
+18 -4
View File
@@ -1,10 +1,13 @@
import { ConfigProvider } from "antd";
import enUS from "antd/locale/en_US";
import ruRU from "antd/locale/ru_RU";
import { useCallback, useEffect, useMemo, useState } from "react";
import { buildTheme } from "@chatballs/ui";
import { applyAppearance, DEFAULT_ACCENT, resolvedDark } from "./shared/appearance";
import { acceptServerLanguage, language } from "./i18n";
import { api, setActiveOrganization } from "./api/client";
import { fetchAgentDirectory } from "./features/agents/model";
import { canAccess, defaultRoute, isManager } from "./auth/access";
@@ -30,6 +33,10 @@ export function App() {
() => buildTheme(resolvedDark(appearanceTheme), appearanceAccent),
[appearanceTheme, appearanceAccent],
);
// Свои строки antd — «Нет данных», подписи пагинации, календарь — берёт из
// собственных каталогов, и без locale остаётся английским посреди русского
// экрана. Язык здесь уже окончательный: смена приходит перезагрузкой.
const antdLocale = language() === "en" ? enUS : ruRU;
const [user, setUser] = useState<SessionUser | null>(null);
const [organizationPublicId, setOrganizationPublicId] = useState<string | null>(initialRoute.organizationPublicId);
const [totpChallenge, setTotpChallenge] = useState<AuthChallenge | null>(null);
@@ -43,6 +50,10 @@ export function App() {
const { navigate } = navigation;
const useIdentity = useCallback((nextIdentity: AuthenticatedUser, requestedId: string | null) => {
// Язык приходит уже разрешённым сервером. Если он разошёлся с тем, на
// котором отрисован экран, страница перезагрузится — иначе половина
// подписей осталась бы на прежнем языке (см. комментарий в src/i18n).
acceptServerLanguage(nextIdentity.language);
const activeUser = activateOrganization(nextIdentity, requestedId);
setIdentity(nextIdentity);
setUser(activeUser);
@@ -72,8 +83,11 @@ export function App() {
}, [user]);
useEffect(() => {
api<{ authenticated: boolean; user?: AuthenticatedUser }>("/api/v1/auth/session/")
api<{ authenticated: boolean; user?: AuthenticatedUser; language?: string }>("/api/v1/auth/session/")
.then((payload) => {
// Язык установки приходит и для гостя: экран входа принадлежит коробке,
// и на английской машине он должен открываться на языке установки.
if (!payload.authenticated) acceptServerLanguage(payload.language);
if (payload.authenticated && payload.user) {
const activeUser = useIdentity(payload.user, initialRoute.organizationPublicId);
if (activeUser && !initialRoute.organizationPublicId) {
@@ -133,16 +147,16 @@ export function App() {
if (resetting) {
return (
<ConfigProvider theme={antdTheme}>
<ConfigProvider theme={antdTheme} locale={antdLocale}>
<AuthResetPassword onDone={() => { setResetting(false); window.history.replaceState({}, "", pathFromRoute("chat")); }} />
</ConfigProvider>
);
}
if (sessionLoading) return <ConfigProvider theme={antdTheme}><LoadingScreen /></ConfigProvider>;
if (sessionLoading) return <ConfigProvider theme={antdTheme} locale={antdLocale}><LoadingScreen /></ConfigProvider>;
return (
<ConfigProvider theme={antdTheme}>
<ConfigProvider theme={antdTheme} locale={antdLocale}>
{totpChallenge ? (
<AuthTotpCode challenge={totpChallenge} onVerified={(nextUser) => { setTotpChallenge(null); landAfterAuth(nextUser); }} />
) : !identity ? (
+6 -4
View File
@@ -1,3 +1,5 @@
import { t } from "../i18n";
const configuredApiBase = (import.meta.env.VITE_API_BASE_URL ?? "").replace(/\/+$/, "");
let activeOrganizationPublicId: string | null = null;
@@ -37,7 +39,7 @@ export class ApiError<TPayload extends { detail?: string } = ApiErrorPayload> ex
readonly payload: TPayload;
constructor(status: number, payload: TPayload) {
super(payload.detail ?? "Ошибка запроса");
super(payload.detail ?? t("common.request_failed"));
this.name = "ApiError";
this.status = status;
this.payload = payload;
@@ -101,7 +103,7 @@ export async function api<T>(path: string, init: RequestInit = {}): Promise<T> {
headers,
});
if (!response.ok) {
const payload = await response.json().catch(() => ({ detail: "Ошибка запроса" })) as ApiErrorPayload;
const payload = await response.json().catch(() => ({ detail: t("common.request_failed") })) as ApiErrorPayload;
throw new ApiError(response.status, payload);
}
if (response.status === 204 || response.status === 205) return undefined as T;
@@ -123,7 +125,7 @@ export function apiUpload<T>(path: string, form: FormData, onProgress?: (percent
if (event.lengthComputable) onProgress(Math.round((event.loaded / event.total) * 100));
};
}
request.onerror = () => reject(new ApiError(0, { detail: "Ошибка сети" }));
request.onerror = () => reject(new ApiError(0, { detail: t("shared.network_error") }));
request.onload = () => {
let payload: unknown = null;
try {
@@ -135,7 +137,7 @@ export function apiUpload<T>(path: string, form: FormData, onProgress?: (percent
resolve(payload as T);
return;
}
reject(new ApiError(request.status, (payload ?? { detail: "Ошибка запроса" }) as ApiErrorPayload));
reject(new ApiError(request.status, (payload ?? { detail: t("common.request_failed") }) as ApiErrorPayload));
};
request.send(form);
});
+4 -2
View File
@@ -12,8 +12,10 @@ function userWith(role: Role, capabilities: string[] = []): SessionUser {
totpEnabled: false,
totpLastUsedAt: null,
deliveryMode: "CLOUD",
uiTheme: "SYSTEM",
uiAccent: "",
uiTheme: "SYSTEM",
uiAccent: "",
uiLanguage: "",
language: "ru",
memberships: [],
organizationPublicId: "org-1",
organization: "org",
@@ -9,6 +9,7 @@ import {
type AuditPeriod,
type AuditQuery,
} from "./model";
import { t } from "../../i18n";
// Фильтры журнала: поиск · период · раздел · сотрудник · результат · «Сбросить».
// Те же контролы, что у списков Сотрудников и Контактов — SearchInput,
@@ -27,20 +28,20 @@ export function AuditFilters({ actors, categories, query, setQuery, reset }: {
const [actorOpen, setActorOpen] = useState(false);
const periodLabel = AUDIT_PERIODS.find(([value]) => value === query.period)?.[1] ?? "";
const categoryLabel = categories.find((item) => item.value === query.category)?.label ?? "Все";
const actorLabel = actors.find((item) => item.value === query.actor)?.label ?? "Все";
const categoryLabel = categories.find((item) => item.value === query.category)?.label ?? t("common.all");
const actorLabel = actors.find((item) => item.value === query.actor)?.label ?? t("common.all");
return (
<div className="audit-filters">
<SearchInput
className="audit-search"
hotkey="/"
placeholder="Поиск по действию, объекту или сотруднику…"
placeholder={t("admin.search_by_action_object_or")}
value={query.q}
onChange={(value) => setQuery({ q: value })}
/>
<FilterDropdown
caption="Период"
caption={t("admin.period")}
label={periodLabel}
open={periodOpen}
options={AUDIT_PERIODS.map(([value, label]) => ({ value, label }))}
@@ -49,27 +50,27 @@ export function AuditFilters({ actors, categories, query, setQuery, reset }: {
onSelect={(value) => setQuery({ period: value as AuditPeriod })}
/>
<FilterDropdown
caption="Раздел"
caption={t("admin.section")}
label={categoryLabel}
open={categoryOpen}
options={[{ value: "", label: "Все" }, ...categories]}
options={[{ value: "", label: t("common.all") }, ...categories]}
selected={query.category ? [query.category] : []}
onOpenChange={setCategoryOpen}
onSelect={(value) => setQuery({ category: value })}
/>
{actors.length > 1 && (
<FilterDropdown
caption="Сотрудник"
caption={t("common.operator")}
label={actorLabel}
open={actorOpen}
options={[{ value: "", label: "Все" }, ...actors]}
options={[{ value: "", label: t("common.all") }, ...actors]}
selected={query.actor ? [query.actor] : []}
onOpenChange={setActorOpen}
onSelect={(value) => setQuery({ actor: value })}
/>
)}
<Segmented
items={[["", "Все"], ["SUCCESS", "Выполнено"], ["DENIED", "Отклонено"], ["FAILED", "Ошибка"]]}
items={[["", t("common.all")], ["SUCCESS", t("admin.succeeded")], ["DENIED", t("admin.rejected")], ["FAILED", t("common.error")]]}
value={query.result}
setValue={(value) => setQuery({ result: value })}
/>
@@ -78,9 +79,7 @@ export function AuditFilters({ actors, categories, query, setQuery, reset }: {
className={`audit-reset ${auditQueryIsDirty(query) ? "is-dirty" : ""}`}
type="button"
onClick={reset}
>
Сбросить
</button>
>{t("common.reset")}</button>
</div>
);
}
@@ -1,11 +1,11 @@
import { EmptyState, LoadingState, PageHeader } from "../../shared/ui";
import { Pagination } from "../../shared/Pagination";
import { Button } from "../../shared/ui-controls";
import { pluralRu } from "../../shared/utils";
import { AuditFilters } from "./AuditFilters";
import { AuditTable } from "./AuditTable";
import { auditQueryIsDirty } from "./model";
import { useAudit } from "./useAudit";
import { t, tn } from "../../i18n";
// «Аудит действий» — отдельный экран из субменю «Настроек». Журнал отдавался
// последними 50 событиями без фильтров и без страниц: дальше пятидесятого
@@ -18,10 +18,10 @@ export function AuditPage() {
const header = (
<PageHeader
title="Аудит"
title={t("common.audit")}
text={payload
? `Журнал действий в организации · ${pluralRu(payload.total, ["событие", "события", "событий"])}`
: "Журнал действий в организации"}
? t("admin.audit_log_with_total", { total: tn("plural.events", payload.total) })
: t("admin.log_actions_organization")}
/>
);
@@ -30,7 +30,7 @@ export function AuditPage() {
<div className="audit-page">
{header}
<EmptyState title={error} />
<Button variant="secondary" onClick={() => void audit.reload()}>Повторить</Button>
<Button variant="secondary" onClick={() => void audit.reload()}>{t("common.try_again")}</Button>
</div>
);
}
@@ -56,13 +56,13 @@ export function AuditPage() {
таблица моргает пустотой на каждый ввод в поиске. */}
<div className={`audit-results ${loading ? "is-loading" : ""}`}>
{payload.items.length === 0
? <EmptyState title={dirty ? "Под фильтры ничего не подошло" : "Событий пока нет"} />
? <EmptyState title={dirty ? t("admin.nothing_matched_filters") : t("admin.no_events_yet")} />
: <AuditTable events={payload.items} />}
</div>
{payload.pageCount > 1 && (
<Pagination
className="audit-pagination"
note={`Показано ${payload.items.length} из ${payload.total} · по ${payload.pageSize} на странице`}
note={t("common.shown_of_page", { shown: payload.items.length, total: payload.total, size: payload.pageSize })}
page={payload.page}
pageCount={payload.pageCount}
onPage={(page) => setQuery({ page })}
@@ -3,6 +3,7 @@ import { Fragment, useState } from "react";
import { Icon } from "../../shared/icons";
import { shortDate, shortDateYear } from "../../shared/utils";
import type { AuditEvent } from "./model";
import { t } from "../../i18n";
// Журнал действий. Три вещи, без которых он был нечитаем:
// * день отбивается заголовком — иначе сотни строк идут сплошняком;
@@ -26,16 +27,16 @@ function dayLabel(value: string): string {
const today = new Date();
const yesterday = new Date();
yesterday.setDate(today.getDate() - 1);
if (dayKey(value) === dayKey(today.toISOString())) return "Сегодня";
if (dayKey(value) === dayKey(yesterday.toISOString())) return "Вчера";
if (dayKey(value) === dayKey(today.toISOString())) return t("common.today");
if (dayKey(value) === dayKey(yesterday.toISOString())) return t("admin.yesterday");
return date.getFullYear() === today.getFullYear() ? shortDate(date) : shortDateYear(date);
}
function detailLines(event: AuditEvent): Array<[string, string]> {
const lines: Array<[string, string]> = [];
if (event.actorEmail) lines.push(["Сотрудник", `${event.actor} · ${event.actorEmail}`]);
lines.push(["Код действия", event.action]);
if (event.objectType) lines.push(["Объект", `${event.objectType}${event.objectId ? ` · ${event.objectId}` : ""}`]);
if (event.actorEmail) lines.push([t("common.operator"), `${event.actor} · ${event.actorEmail}`]);
lines.push([t("admin.action_code"), event.action]);
if (event.objectType) lines.push([t("common.object"), `${event.objectType}${event.objectId ? ` · ${event.objectId}` : ""}`]);
if (event.sourceIp) lines.push(["IP", event.sourceIp]);
if (event.correlationId) lines.push(["Correlation id", event.correlationId]);
Object.entries(event.details ?? {}).forEach(([key, value]) => {
@@ -53,11 +54,11 @@ export function AuditTable({ events }: { events: AuditEvent[] }) {
<table className="baseline-table audit-table">
<thead>
<tr>
<th className="audit-col-time">Время</th>
<th className="audit-col-actor">Сотрудник</th>
<th>Действие</th>
<th className="audit-col-object">Объект</th>
<th className="audit-col-result">Результат</th>
<th className="audit-col-time">{t("admin.time")}</th>
<th className="audit-col-actor">{t("common.operator")}</th>
<th>{t("common.action")}</th>
<th className="audit-col-object">{t("common.object")}</th>
<th className="audit-col-result">{t("admin.result")}</th>
<th className="audit-col-toggle" />
</tr>
</thead>
@@ -2,6 +2,7 @@ import { useRef, useState } from "react";
import { DecisionDialog } from "../../shared/DecisionDialog";
import { Button } from "../../shared/ui-controls";
import { t } from "../../i18n";
export function OrganizationLogoField({
logoUrl,
@@ -25,12 +26,12 @@ export function OrganizationLogoField({
<div className="administration-logo-field">
<div className="administration-logo-preview">
{logoUrl
? <img src={logoUrl} alt={`Логотип ${name}`} />
? <img src={logoUrl} alt={t("admin.organization_logo_alt", { name })} />
: <span>{name.trim().slice(0, 2).toUpperCase() || "CR"}</span>}
</div>
<div className="administration-logo-copy">
<strong>Логотип</strong>
<span>PNG, JPEG или WebP · до 2 МБ. Виден в сайдбаре, виджете и письмах.</span>
<strong>{t("admin.logo")}</strong>
<span>{t("admin.png_jpeg_or_webp_up")}</span>
</div>
{!disabled && (
<div className="administration-logo-actions">
@@ -49,17 +50,13 @@ export function OrganizationLogoField({
variant="secondary"
disabled={saving}
onClick={() => input.current?.click()}
>
Загрузить
</Button>
>{t("common.upload")}</Button>
<button
className="administration-logo-remove"
type="button"
disabled={saving || !logoUrl}
onClick={() => setConfirmingRemoval(true)}
>
Убрать
</button>
>{t("common.remove")}</button>
</div>
)}
<DecisionDialog
@@ -67,13 +64,11 @@ export function OrganizationLogoField({
onClose={() => setConfirmingRemoval(false)}
tone="danger"
icon="trash"
title="Удалить логотип?"
description="Вместо него в меню снова будет показан знак Chatballs."
title={t("admin.remove_logo")}
description={t("admin.chatballs_mark_will_shown_menu")}
actions={(
<>
<Button variant="secondary" onClick={() => setConfirmingRemoval(false)}>
Отмена
</Button>
<Button variant="secondary" onClick={() => setConfirmingRemoval(false)}>{t("common.cancel")}</Button>
<Button
variant="danger-outline"
disabled={saving}
@@ -81,9 +76,7 @@ export function OrganizationLogoField({
setConfirmingRemoval(false);
onRemove();
}}
>
Удалить
</Button>
>{t("common.delete")}</Button>
</>
)}
/>
@@ -3,12 +3,14 @@ import type { FormEvent } from "react";
import { FormField, SelectField } from "../../shared/form-controls";
import { shortDateTime, timezoneLabel } from "../../shared/utils";
import { Button } from "../../shared/ui-controls";
import type { OrganizationLanguageOption } from "./api";
import type { OrganizationSettings } from "./model";
import { OrganizationLogoField } from "./OrganizationLogoField";
import { t } from "../../i18n";
function savedLabel(updatedAt: string | null): string {
if (!updatedAt) return "Ещё не сохранялось";
return `Сохранено ${shortDateTime(updatedAt)}`;
if (!updatedAt) return t("common.never_saved_yet");
return t("time.saved_at", { time: shortDateTime(updatedAt) });
}
export function OrganizationSettingsForm({
@@ -18,6 +20,7 @@ export function OrganizationSettingsForm({
message,
error,
timezones,
languages,
onChange,
onSave,
onUploadLogo,
@@ -29,6 +32,7 @@ export function OrganizationSettingsForm({
message: string;
error: string;
timezones: string[];
languages: OrganizationLanguageOption[];
onChange: (next: OrganizationSettings) => void;
onSave: () => void;
onUploadLogo: (file: File) => void;
@@ -52,7 +56,7 @@ export function OrganizationSettingsForm({
<div className="administration-fields">
<div className="administration-field-wide">
<FormField
label="Название организации"
label={t("admin.organization_name")}
value={organization.name}
disabled={!canManage}
onChange={canManage
@@ -61,18 +65,31 @@ export function OrganizationSettingsForm({
/>
</div>
<SelectField
label="Часовой пояс"
label={t("admin.time_zone")}
value={organization.timezone}
disabled={!canManage}
onChange={(timezone) => onChange({ ...organization, timezone })}
options={timezones.map((timezone) => [timezone, timezoneLabel(timezone)])}
/>
<SelectField
label="Валюта"
label={t("settings.language")}
value={organization.language}
disabled={!canManage}
onChange={(language) => onChange({ ...organization, language })}
// Первый пункт — не язык, а отказ от выбора: организация без своего
// языка следует за установкой, и владелец видит это словами.
options={[
["", t("settings.language_as_installation")],
...languages.map((item): [string, string] => [item.code, item.label]),
]}
hint={t("settings.language_org_hint")}
/>
<SelectField
label={t("admin.currency")}
value={organization.currency}
disabled={!canManage}
onChange={(currency) => onChange({ ...organization, currency })}
options={[["RUB", "Российский рубль (RUB)"]]}
options={[["RUB", t("admin.russian_rouble_rub")]]}
/>
</div>
{error && <div className="administration-message error" role="alert">{error}</div>}
@@ -85,7 +102,7 @@ export function OrganizationSettingsForm({
variant="primary"
disabled={saving || !organization.name.trim()}
>
{saving ? "Сохранение" : "Сохранить"}
{saving ? t("common.saving") : t("common.save")}
</Button>
</div>
)}
@@ -3,11 +3,18 @@ import type { AuditPayload, AuditQuery, OrganizationSettings } from "./model";
const BASE = "/api/v1/company/administration/";
export type OrganizationLanguageOption = { code: string; label: string };
export async function loadOrganizationSettings(): Promise<{
organization: OrganizationSettings;
timezones: string[];
languages: OrganizationLanguageOption[];
}> {
return api<{ organization: OrganizationSettings; timezones: string[] }>(BASE);
return api<{
organization: OrganizationSettings;
timezones: string[];
languages: OrganizationLanguageOption[];
}>(BASE);
}
export async function saveOrganizationSettings(
@@ -19,6 +26,7 @@ export async function saveOrganizationSettings(
name: settings.name,
timezone: settings.timezone,
currency: settings.currency,
language: settings.language,
}),
});
return payload.organization;
@@ -1,5 +1,6 @@
import type { SessionUser } from "../../types";
import { hasCapability } from "../../auth/access";
import { t } from "../../i18n";
// Аудит живёт на своём экране (AuditPage) и своём хуке; useAdministration
// остался только под «Организацию» в Настройках.
@@ -9,6 +10,8 @@ export type OrganizationSettings = {
name: string;
timezone: string;
currency: string;
// Пустая строка — «как в установке»: организация не обязана выбирать язык.
language: string;
logoUrl: string | null;
// Когда настройки сохраняли в последний раз — подпись у кнопки (кадр N1).
updatedAt: string | null;
@@ -65,11 +68,11 @@ export type AuditPayload = {
};
export const AUDIT_PERIODS: Array<[AuditPeriod, string]> = [
["today", "Сегодня"],
["7d", "7 дней"],
["30d", "30 дней"],
["90d", "90 дней"],
["all", "Всё время"],
["today", t("common.today")],
["7d", t("admin.7_days")],
["30d", t("admin.30_days")],
["90d", t("admin.90_days")],
["all", t("admin.all_time")],
];
export const EMPTY_AUDIT_QUERY: AuditQuery = {
@@ -8,7 +8,9 @@ import {
saveOrganizationSettings,
uploadOrganizationLogo,
} from "./api";
import type { OrganizationLanguageOption } from "./api";
import type { AdministrationSection, OrganizationSettings } from "./model";
import { t } from "../../i18n";
export function useAdministration({
section,
@@ -21,6 +23,7 @@ export function useAdministration({
}) {
const [organization, setOrganization] = useState<OrganizationSettings | null>(null);
const [timezones, setTimezones] = useState<string[]>([]);
const [languages, setLanguages] = useState<OrganizationLanguageOption[]>([]);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [message, setMessage] = useState("");
@@ -40,8 +43,9 @@ export function useAdministration({
const settings = await loadOrganizationSettings();
setOrganization(settings.organization);
setTimezones(settings.timezones);
setLanguages(settings.languages);
} catch (loadError) {
setError(loadError instanceof ApiError ? loadError.message : "Не удалось загрузить данные");
setError(loadError instanceof ApiError ? loadError.message : t("admin.could_not_load_data"));
} finally {
setLoading(false);
}
@@ -64,7 +68,7 @@ export function useAdministration({
setMessage(successMessage);
await refreshIdentity();
} catch (changeError) {
setError(changeError instanceof ApiError ? changeError.message : "Не удалось сохранить изменения");
setError(changeError instanceof ApiError ? changeError.message : t("admin.could_not_save_changes"));
} finally {
setSaving(false);
}
@@ -77,19 +81,20 @@ export function useAdministration({
organization,
saving,
timezones,
languages,
setOrganization,
reload: load,
save: () => organization && runChange(
() => saveOrganizationSettings(organization),
"Данные организации сохранены",
t("admin.organization_details_saved"),
),
uploadLogo: (file: File) => runChange(
() => uploadOrganizationLogo(file),
"Логотип обновлён",
t("admin.logo_updated"),
),
removeLogo: () => runChange(
removeOrganizationLogo,
"Логотип удалён",
t("admin.logo_removed"),
),
};
}
@@ -4,6 +4,7 @@ import { useDebounced } from "../../shared/useDebounced";
import { usePagedResource } from "../../shared/usePagedResource";
import { loadAudit } from "./api";
import { EMPTY_AUDIT_QUERY, type AuditPayload, type AuditQuery } from "./model";
import { t } from "../../i18n";
// Журнал аудита: фильтры и страница живут на сервере — журнал растёт без
// предела, и отдать его целиком нельзя. Страницей, отложенным поиском и
@@ -27,7 +28,7 @@ export function useAudit() {
(page: number) => loadAudit({ ...filters, page }),
[filters],
);
const journal = usePagedResource<AuditPayload>(load, filters, "Не удалось загрузить журнал");
const journal = usePagedResource<AuditPayload>(load, filters, t("admin.could_not_load_log"));
const setQuery = useCallback((patch: Partial<AuditQuery>) => {
// Страницу двигает подвал списка, фильтры — состояние запроса; смена
@@ -33,6 +33,7 @@ import {
type AgentConnection,
type AgentPatch,
} from "./model";
import { t } from "../../i18n";
// Карточка агента (дизайн-базлайн v2, «Агенты Baseline», кадры G3–G5): слева —
// что агент знает и как говорит (инструкции, знания), справа — как он работает
@@ -118,7 +119,7 @@ export function AgentDetailPage({
if (canManageConnections) void loadMessengers();
}, [canManageConnections, loadMessengers]);
if (missing) return <EmptyState title="Агент не найден" />;
if (missing) return <EmptyState title={t("ai.agent_not_found")} />;
if (failed) return <ErrorScreen retry={() => void reload()} />;
if (!card) return <LoadingState />;
@@ -132,7 +133,7 @@ export function AgentDetailPage({
} catch (caught) {
setFeedback({
kind: "error",
text: caught instanceof ApiError ? caught.payload.detail ?? "Не удалось сохранить" : "Не удалось сохранить",
text: caught instanceof ApiError ? caught.payload.detail ?? t("common.could_not_save") : t("common.could_not_save"),
});
return false;
} finally {
@@ -151,7 +152,7 @@ export function AgentDetailPage({
kind: "error",
text: caught instanceof ApiError && caught.payload.detail
? caught.payload.detail
: "Не удалось изменить статус AI",
: t("ai.could_not_change_ai_status"),
});
} finally {
setBusy(false);
@@ -169,8 +170,8 @@ export function AgentDetailPage({
kind: "warning",
text:
caught instanceof ApiError && caught.status === 409
? "Агента нельзя удалить: есть диалоги или подключения. Отвяжите подключения и закройте диалоги."
: "Не удалось удалить агента",
? t("ai.agent_cannot_deleted_has_conversations")
: t("ai.could_not_delete_agent"),
});
} finally {
setBusy(false);
@@ -184,7 +185,7 @@ export function AgentDetailPage({
setCard(saved.agent);
void loadMessengers();
} catch {
setFeedback({ kind: "error", text: "Не удалось отвязать подключение" });
setFeedback({ kind: "error", text: t("ai.could_not_detach_connection") });
} finally {
setBusy(false);
}
@@ -202,8 +203,8 @@ export function AgentDetailPage({
kind: "error",
text:
caught instanceof ApiError && caught.status === 409
? "Подключение уже привязано к другому агенту"
: "Не удалось привязать подключение",
? t("ai.connection_already_bound_another_agent")
: t("ai.could_not_bind_connection"),
});
} finally {
setBusy(false);
@@ -216,14 +217,14 @@ export function AgentDetailPage({
// Выключение агента и удаление живут в этом меню, а не отдельными кнопками
// под карточкой: на странице остаётся один переключатель AI.
const menuItems = [
{ key: "copy-code", label: <button type="button" onClick={() => { setMenuOpen(false); void navigator.clipboard?.writeText(card.code); }}><Icon name="copy" size={15} />Скопировать код</button> },
{ key: "copy-code", label: <button type="button" onClick={() => { setMenuOpen(false); void navigator.clipboard?.writeText(card.code); }}><Icon name="copy" size={15} />{t("ai.copy_snippet")}</button> },
{ type: "divider" as const },
{
key: "active",
disabled: busy,
label: (
<button type="button" onClick={() => { setMenuOpen(false); void apply({ isActive: !card.isActive }); }}>
<Icon name={card.isActive ? "xCircle" : "check"} size={15} />{card.isActive ? "Выключить агента" : "Включить агента"}
<Icon name={card.isActive ? "xCircle" : "check"} size={15} />{card.isActive ? t("ai.turn_agent_off") : t("ai.turn_agent")}
</button>
),
},
@@ -232,15 +233,14 @@ export function AgentDetailPage({
disabled: busy,
label: (
<button className="danger" type="button" onClick={() => { setMenuOpen(false); setDeleting(true); }}>
<Icon name="trash" size={15} />Удалить агента
</button>
<Icon name="trash" size={15} />{t("ai.delete_agent")}</button>
),
},
];
return (
<div className="agent-page">
<BackLink label="Агенты" onClick={openAgents} />
<BackLink label={t("common.agents")} onClick={openAgents} />
<header className="agent-head">
<span className="agent-head-tile" style={{ background: tile.background, color: tile.color }}>
@@ -252,22 +252,22 @@ export function AgentDetailPage({
<b className="agents-status" style={{ background: status.bg, color: status.color }}><i />{status.text}</b>
</div>
<p>
<span><i style={{ background: card.groupId === null ? "var(--n-5)" : groupColorOf(card.groupId, card.groupColor) }} />{card.groupName ?? "Без группы"}</span>
<span><i style={{ background: card.groupId === null ? "var(--n-5)" : groupColorOf(card.groupId, card.groupColor) }} />{card.groupName ?? t("common.no_group")}</span>
<i />
<code>{card.code}</code>
<i />
{openDialogsLine(card.counters.openConversations)}
<i />
создан {createdLabel(card.createdAt)}
{t("ai.created_on", { date: createdLabel(card.createdAt) })}
</p>
</div>
{canManage && (
<div className="agent-head-actions">
<Button variant="secondary" className="agent-toggle" icon={running ? "pause" : "bolt"} disabled={busy} onClick={() => void toggleAi()}>
{running ? "Остановить AI" : "Запустить AI"}
{running ? t("ai.stop_ai") : t("ai.start_ai")}
</Button>
<Dropdown menu={{ items: menuItems }} open={menuOpen} onOpenChange={setMenuOpen} trigger={["click"]} overlayClassName="app-dropdown">
<button className="agent-head-menu" type="button" aria-label="Действия агента" title="Действия"><Icon name="more" size={17} strokeWidth={2} /></button>
<button className="agent-head-menu" type="button" aria-label={t("ai.agent_actions")} title={t("common.actions")}><Icon name="more" size={17} strokeWidth={2} /></button>
</Dropdown>
</div>
)}
@@ -277,11 +277,11 @@ export function AgentDetailPage({
<div className="agent-blocker">
<Icon name="alert" size={18} strokeWidth={2.2} />
<div>
<strong>AI не может отвечать: провайдер не выбран</strong>
<small>Добавьте ключ в «Настройки → AI-провайдер» и выберите провайдера в блоке «Модель». До этого диалоги агента ждут человека.</small>
<strong>{t("ai.ai_cannot_reply_no_provider")}</strong>
<small>{t("ai.add_key_under_settings_ai")}</small>
</div>
{canManage && (
<button type="button" onClick={openAiProvider}>Открыть настройки<Icon name="external" size={13} strokeWidth={2.2} /></button>
<button type="button" onClick={openAiProvider}>{t("ai.open_settings")}<Icon name="external" size={13} strokeWidth={2.2} /></button>
)}
</div>
)}
@@ -311,14 +311,14 @@ export function AgentDetailPage({
{deleting && (
<Modal
open
title="Удалить агента?"
okText="Удалить"
cancelText="Отмена"
title={t("ai.delete_agent_2")}
okText={t("common.delete")}
cancelText={t("common.cancel")}
okButtonProps={{ danger: true, disabled: busy }}
onOk={() => void removeAgent()}
onCancel={() => setDeleting(false)}
>
<p>Агент «{card.name}» будет удалён безвозвратно. Диалоги и подключения не дают удалить агента.</p>
<p>{t("ai.agent_will_be_deleted", { name: card.name })}</p>
</Modal>
)}
</div>
@@ -328,9 +328,9 @@ export function AgentDetailPage({
// --- Инструкции (кадры G3/G5) ---
const INSTRUCTION_FIELDS = [
{ key: "persona", label: "Кто он и что делает", hint: "персонализация", rows: 7, placeholder: "" },
{ key: "tone", label: "Как он должен говорить", hint: "тон", rows: 7, placeholder: "Например: спокойно и вежливо, на «вы»" },
{ key: "instructions", label: "Правила работы", hint: "инструкции", rows: 10, placeholder: "Например: не называй цены, направляй к консультанту" },
{ key: "persona", label: t("ai.who_what_does"), hint: t("ai.persona"), rows: 7, placeholder: "" },
{ key: "tone", label: t("ai.how_should_speak"), hint: t("ai.tone"), rows: 7, placeholder: t("ai.example_calmly_politely_formal_address") },
{ key: "instructions", label: t("ai.working_rules"), hint: t("ai.instructions"), rows: 10, placeholder: t("ai.example_do_not_quote_prices") },
] as const;
function InstructionsCard({ card, canManage, busy, apply }: {
@@ -348,8 +348,8 @@ function InstructionsCard({ card, canManage, busy, apply }: {
return (
<section className="agent-card">
<div className="agent-card-head">
<h3>Инструкции</h3>
<small>Системный промпт собирается из трёх частей в этом порядке</small>
<h3>{t("ai.instructions_2")}</h3>
<small>{t("ai.system_prompt_assembled_from_three")}</small>
</div>
<div className="agent-instructions">
{INSTRUCTION_FIELDS.map((field) => (
@@ -369,9 +369,9 @@ function InstructionsCard({ card, canManage, busy, apply }: {
</div>
{canManage && dirty && (
<div className="agent-dirty">
<small>Есть несохранённые изменения — применятся в runtime сразу</small>
<button type="button" disabled={busy} onClick={() => setDraft({ persona: card.persona, tone: card.tone, instructions: card.instructions })}>Отменить</button>
<button className="is-primary" type="button" disabled={busy} onClick={() => void apply(draft)}>Сохранить</button>
<small>{t("ai.there_unsaved_changes_they_take")}</small>
<button type="button" disabled={busy} onClick={() => setDraft({ persona: card.persona, tone: card.tone, instructions: card.instructions })}>{t("ai.undo")}</button>
<button className="is-primary" type="button" disabled={busy} onClick={() => void apply(draft)}>{t("common.save")}</button>
</div>
)}
</section>
@@ -398,15 +398,14 @@ function KnowledgeCard({ card, canManage, openKnowledge, reload }: {
return (
<section className="agent-card">
<div className="agent-card-head is-row">
<div><h3>Знания</h3><small>{knowledgeLine(card)}</small></div>
<div><h3>{t("ai.knowledge")}</h3><small>{knowledgeLine(card)}</small></div>
{canManage && (
<button className="agent-inline-button" type="button" onClick={() => setPicking(true)}>
<Icon name="plus" size={13} strokeWidth={2.2} />Выбрать
</button>
<Icon name="plus" size={13} strokeWidth={2.2} />{t("ai.select")}</button>
)}
</div>
{rows.length === 0 ? (
<p className="agent-knowledge-empty">Знания не прикреплены — агент отвечает только по инструкциям. Выберите статьи из библиотеки или портала поддержки.</p>
<p className="agent-knowledge-empty">{t("ai.no_knowledge_attached_so_agent")}</p>
) : (
<div className="agent-knowledge-list">
{rows.map((row) => (
@@ -418,7 +417,7 @@ function KnowledgeCard({ card, canManage, openKnowledge, reload }: {
{row.chip && <small className={`agent-chip is-${row.chipTone}`}>{row.chip}</small>}
<small className="agent-knowledge-meta">{row.meta}</small>
{canManage && (
<button className="agent-knowledge-remove" type="button" aria-label="Убрать" title="Убрать" onClick={() => void detach(row.kind, row.id)}>
<button className="agent-knowledge-remove" type="button" aria-label={t("common.remove")} title={t("common.remove")} onClick={() => void detach(row.kind, row.id)}>
<Icon name="close" size={12} strokeWidth={2.4} />
</button>
)}
@@ -460,17 +459,17 @@ function AssignmentCard({ card, groups, canManage, busy, apply }: {
return (
<section className="agent-card is-side">
<h3>Назначение</h3>
<p>Группа определяет, кто из сотрудников видит диалоги агента; без группы — видны всем.</p>
<h3>{t("ai.assignment")}</h3>
<p>{t("ai.group_decides_which_operators_see")}</p>
<div className="agent-side-fields">
<label className="agent-field">
<span>Название</span>
<span>{t("common.title")}</span>
{canManage
? <input value={name} onChange={(event) => setName(event.target.value)} onBlur={() => { if (dirty) void apply({ name: name.trim() }); }} />
: <span className="agent-field-static">{card.name}</span>}
</label>
<label className="agent-field is-select">
<span>Группа</span>
<span>{t("common.group")}</span>
<span className="agent-field-control">
<i className="agent-group-dot" style={{ background: card.groupId === null ? "var(--n-5)" : groupColorOf(card.groupId, card.groupColor) }} />
{canManage ? (
@@ -479,11 +478,11 @@ function AssignmentCard({ card, groups, canManage, busy, apply }: {
value={card.groupId === null ? "" : String(card.groupId)}
onChange={(event) => void apply({ groupId: event.target.value ? Number(event.target.value) : null })}
>
<option value="">Без группы</option>
<option value="">{t("common.no_group")}</option>
{groups.map((group) => <option value={String(group.id)} key={group.id}>{group.name}</option>)}
</select>
) : (
<span className="agent-field-static">{card.groupName ?? "Без группы"}</span>
<span className="agent-field-static">{card.groupName ?? t("common.no_group")}</span>
)}
<Icon name="chevron" size={14} strokeWidth={2.2} />
</span>
@@ -515,11 +514,11 @@ function ModelCard({ card, providers, canManage, busy, apply }: {
return (
<section className="agent-card is-side">
<h3>Модель</h3>
<p>Ключ провайдера — в «Настройки → AI-провайдер». Модель выбирается из каталога провайдера.</p>
<h3>{t("common.model")}</h3>
<p>{t("ai.provider_key_lives_under_settings")}</p>
<div className="agent-side-fields">
<label className={`agent-field is-select ${missingProvider ? "is-invalid" : ""}`}>
<span>Провайдер</span>
<span>{t("ai.provider")}</span>
<span className="agent-field-control">
{canManage ? (
<select
@@ -527,24 +526,24 @@ function ModelCard({ card, providers, canManage, busy, apply }: {
value={card.providerIntegrationId ? String(card.providerIntegrationId) : ""}
onChange={(event) => void apply({ providerIntegrationId: event.target.value ? Number(event.target.value) : null })}
>
<option value="">Не выбран</option>
<option value="">{t("ai.not_selected")}</option>
{providers.map((item) => <option value={String(item.id)} key={item.id}>{item.name}</option>)}
</select>
) : (
<span className="agent-field-static">{providerName || "Не выбран"}</span>
<span className="agent-field-static">{providerName || t("ai.not_selected")}</span>
)}
<Icon name="chevron" size={14} strokeWidth={2.2} />
</span>
</label>
<label className="agent-field is-model">
<span>Модель</span>
<span>{t("common.model")}</span>
<span className="agent-field-control">
<span className={`agent-field-static ${missingProvider ? "is-placeholder" : ""}`}>{missingProvider ? "выберите провайдера" : card.model}</span>
<span className={`agent-field-static ${missingProvider ? "is-placeholder" : ""}`}>{missingProvider ? t("ai.pick_provider") : card.model}</span>
<Icon name="search" size={14} strokeWidth={1.8} />
</span>
</label>
<div className="agent-limit">
<span>Лимит в день</span>
<span>{t("ai.daily_limit")}</span>
<span>
{canManage
? <input value={limit} inputMode="decimal" placeholder="0.00" onChange={(event) => setLimit(event.target.value)} onBlur={saveLimit} />
@@ -572,17 +571,17 @@ function ConnectionsCard({ card, canManage, busy, available, openIntegrations, b
return (
<section className="agent-card is-side">
<div className="agent-card-head is-tight">
<h3>Подключения</h3>
<h3>{t("common.connections")}</h3>
{canManage && (
<button className="link has-icon" type="button" onClick={openIntegrations}>Интеграции<Icon name="external" size={13} strokeWidth={2.2} /></button>
<button className="link has-icon" type="button" onClick={openIntegrations}>{t("common.integrations")}<Icon name="external" size={13} strokeWidth={2.2} /></button>
)}
</div>
<p>Через них клиенты попадают к агенту. Одно подключение — один агент.</p>
<p>{t("ai.customers_reach_agent_through_these")}</p>
{card.connections.map((connection) => (
<ConnectionRow
connection={connection}
status={connectionStatusMeta(connection.status)}
action={canManage ? { label: "Отвязать", run: () => unbind(connection.id) } : null}
action={canManage ? { label: t("ai.unbind"), run: () => unbind(connection.id) } : null}
busy={busy}
key={connection.id}
/>
@@ -590,9 +589,9 @@ function ConnectionsCard({ card, canManage, busy, available, openIntegrations, b
{canManage && available.map((integration) => (
<ConnectionRow
connection={{ id: integration.id, provider: integration.provider, name: integration.name, status: integration.status, botUsername: "", email: "", allowedOrigins: [], widgetPublicKey: "" }}
subtitle="Свободное подключение"
status={{ text: "Свободно", bg: "var(--n-9)", color: "var(--n-4)" }}
action={{ label: "Привязать", run: () => bind(integration.id) }}
subtitle={t("ai.free_connection")}
status={{ text: t("ai.free"), bg: "var(--n-9)", color: "var(--n-4)" }}
action={{ label: t("ai.bind"), run: () => bind(integration.id) }}
busy={busy}
key={`free-${integration.id}`}
/>
@@ -600,11 +599,11 @@ function ConnectionsCard({ card, canManage, busy, available, openIntegrations, b
{widget && (
<div className="agent-widget">
<div>
<small>Код вставки виджета</small>
<CopyButton className="agent-widget-copy" label="Копировать" value={webWidgetSnippet(widget.widgetPublicKey)} />
<small>{t("ai.widget_embed_snippet")}</small>
<CopyButton className="agent-widget-copy" label={t("common.copy")} value={webWidgetSnippet(widget.widgetPublicKey)} />
</div>
<code>{webWidgetSnippet(widget.widgetPublicKey)}</code>
<small>Вставьте перед &lt;/body&gt; на сайте</small>
<small>{t("ai.paste_before_lt_body_gt")}</small>
</div>
)}
</section>
@@ -8,6 +8,7 @@ import { shortDate } from "../../shared/utils";
import { fetchKnowledgeList } from "../ai/knowledge/api";
import { listPortalArticles, listSupportPortals } from "../support-portals/api";
import type { AgentCard } from "./model";
import { t } from "../../i18n";
// «Выбрать» в блоке «Знания» карточки агента (кадры G3/G5): один список из
// материалов библиотеки и опубликованных статей порталов. Агент отвечает
@@ -56,7 +57,7 @@ export function AgentKnowledgeDialog({ card, onClose, onSave }: {
id: article.id,
title: article.publishedRevision?.title ?? article.slug,
icon: "globe" as const,
meta: `Портал · ${portal.name}`,
meta: t("ai.portal_named", { name: portal.name }),
disabled: false,
})));
setChoices([
@@ -66,7 +67,7 @@ export function AgentKnowledgeDialog({ card, onClose, onSave }: {
id: item.id,
title: item.title,
icon: "doc",
meta: item.isEnabled ? `обновлено ${shortDate(item.updatedAt)}` : "Выключено",
meta: item.isEnabled ? t("ai.updated_on", { date: shortDate(item.updatedAt) }) : t("common.off"),
disabled: false,
})),
...articles,
@@ -105,20 +106,20 @@ export function AgentKnowledgeDialog({ card, onClose, onSave }: {
});
onClose();
} catch (caught) {
setError(caught instanceof Error ? caught.message : "Не удалось сохранить выбор");
setError(caught instanceof Error ? caught.message : t("ai.could_not_save_selection"));
setSaving(false);
}
}
return (
<Modal className="agent-knowledge-modal" open width={560} title="Выбрать знания" onCancel={onClose} footer={null} destroyOnClose>
<p className="agent-create-lead">Агент отвечает только по выбранным материалам: знаниям библиотеки и опубликованным статьям порталов.</p>
<SearchInput className="agent-knowledge-search" placeholder="Поиск по названию" value={search} onChange={setSearch} />
<Modal className="agent-knowledge-modal" open width={560} title={t("ai.select_knowledge")} onCancel={onClose} footer={null} destroyOnClose>
<p className="agent-create-lead">{t("ai.agent_answers_only_from_selected")}</p>
<SearchInput className="agent-knowledge-search" placeholder={t("ai.search_by_title")} value={search} onChange={setSearch} />
{choices === null && !failed && <LoadingState />}
{failed && <div className="agent-form-error">Не удалось загрузить список материалов</div>}
{failed && <div className="agent-form-error">{t("ai.could_not_load_material_list")}</div>}
{choices !== null && (
<div className="agent-knowledge-choices">
{visible.length === 0 && <p className="agent-knowledge-nothing">Ничего не найдено</p>}
{visible.length === 0 && <p className="agent-knowledge-nothing">{t("common.nothing_found")}</p>}
{visible.map((choice) => (
<label className={`agent-knowledge-choice ${picked.has(choice.key) ? "is-picked" : ""}`} key={choice.key}>
<input type="checkbox" checked={picked.has(choice.key)} onChange={() => toggle(choice.key)} />
@@ -131,9 +132,9 @@ export function AgentKnowledgeDialog({ card, onClose, onSave }: {
)}
{error && <div className="agent-form-error">{error}</div>}
<div className="agent-create-actions">
<Button variant="secondary" onClick={onClose}>Отмена</Button>
<Button variant="secondary" onClick={onClose}>{t("common.cancel")}</Button>
<Button variant="primary" disabled={choices === null || saving} onClick={() => void submit()}>
{saving ? "Сохранение…" : "Сохранить"}
{saving ? t("ai.saving") : t("common.save")}
</Button>
</div>
</Modal>
@@ -19,6 +19,7 @@ import {
setAgentAiActive,
type AgentCard,
} from "./model";
import { t } from "../../i18n";
// Список агентов (дизайн-базлайн v2, «Агенты Baseline», кадры G1, G2, S1):
// агент · группа · статус · модель · подключения · открытые · ⋯.
@@ -38,28 +39,28 @@ function CreateAgentModal({ groups, onClose, onCreated }: { groups: EmployeeGrou
const created = await createAgent({ name: name.trim(), groupId });
onCreated(created.agent.id);
} catch (caught) {
setError(caught instanceof Error ? caught.message : "Не удалось создать агента");
setError(caught instanceof Error ? caught.message : t("ai.could_not_create_agent"));
setSubmitting(false);
}
}
return (
<Modal className="agent-create-modal" open width={440} title="Создать агента" onCancel={onClose} footer={null} destroyOnClose>
<p className="agent-create-lead">Один шаг. Инструкции, модель, знания и подключения — потом, на карточке агента.</p>
<Modal className="agent-create-modal" open width={440} title={t("profile.create_agent")} onCancel={onClose} footer={null} destroyOnClose>
<p className="agent-create-lead">{t("ai.one_step_instructions_model_knowledge")}</p>
<div className="agent-create-fields">
<FormField label="Название" value={name} onChange={setName} placeholder="Например: Приёмная" />
<FormField label={t("common.title")} value={name} onChange={setName} placeholder={t("ai.example_front_desk")} />
<SelectField
label="Группа"
label={t("common.group")}
value={groupId === null ? "" : String(groupId)}
onChange={(value) => setGroupId(value ? Number(value) : null)}
options={[["", "Без группы — диалоги видны всем"], ...groups.map((item) => [String(item.id), item.name] as [string, string])]}
options={[["", t("ai.without_group_conversations_visible_everyone")], ...groups.map((item) => [String(item.id), item.name] as [string, string])]}
/>
{error && <div className="agent-form-error">{error}</div>}
</div>
<div className="agent-create-actions">
<Button variant="secondary" onClick={onClose}>Отмена</Button>
<Button variant="secondary" onClick={onClose}>{t("common.cancel")}</Button>
<Button variant="primary" disabled={!name.trim() || submitting} onClick={() => void submit()}>
{submitting ? "Создание…" : "Создать"}
{submitting ? t("ai.creating") : t("ai.create_2")}
</Button>
</div>
</Modal>
@@ -73,14 +74,14 @@ function AgentRow({ card, openAgent, onToggleAi }: { card: AgentCard; openAgent:
const status = agentStatusMeta(card);
const open = card.counters.openConversations;
const menuItems = [
{ key: "open", label: <button type="button" onClick={() => { setMenuOpen(false); openAgent(card.id); }}><Icon name="external" size={15} />Открыть карточку</button> },
{ key: "open", label: <button type="button" onClick={() => { setMenuOpen(false); openAgent(card.id); }}><Icon name="external" size={15} />{t("admin.open_card")}</button> },
{ type: "divider" as const },
{
key: "toggle",
label: (
<button type="button" className={card.aiStatus === "ACTIVE" ? "warning" : ""} onClick={() => { setMenuOpen(false); onToggleAi(card); }}>
<Icon name={card.aiStatus === "ACTIVE" ? "pause" : "bolt"} size={15} />
{card.aiStatus === "ACTIVE" ? "Остановить AI" : "Запустить AI"}
{card.aiStatus === "ACTIVE" ? t("ai.stop_ai") : t("ai.start_ai")}
</button>
),
},
@@ -99,7 +100,7 @@ function AgentRow({ card, openAgent, onToggleAi }: { card: AgentCard; openAgent:
</div>
<span className={`agents-row-group ${card.groupId === null ? "is-none" : ""}`}>
{card.groupId !== null && <i style={{ background: groupColorOf(card.groupId, card.groupColor) }} />}
{card.groupName ?? "Без группы"}
{card.groupName ?? t("common.no_group")}
</span>
<b className="agents-status" style={{ background: status.bg, color: status.color }}><i />{status.text}</b>
<span className="agents-row-model">{agentModelLabel(card)}</span>
@@ -118,7 +119,7 @@ function AgentRow({ card, openAgent, onToggleAi }: { card: AgentCard; openAgent:
</div>
<div className="agents-row-open" style={{ color: open ? "var(--warning-text)" : "var(--n-5)" }}>{open || "—"}</div>
<Dropdown menu={{ items: menuItems }} open={menuOpen} onOpenChange={setMenuOpen} trigger={["click"]} overlayClassName="app-dropdown">
<button className="agents-row-menu" type="button" aria-label="Действия агента" title="Действия" onClick={(event) => event.stopPropagation()}><Icon name="more" size={16} strokeWidth={2} /></button>
<button className="agents-row-menu" type="button" aria-label={t("ai.agent_actions")} title={t("common.actions")} onClick={(event) => event.stopPropagation()}><Icon name="more" size={16} strokeWidth={2} /></button>
</Dropdown>
</div>
);
@@ -138,7 +139,7 @@ export function AgentsPage({
(page: number) => fetchAgentsPage({ group: "all", query: "" }, page),
[],
);
const agents = usePagedResource(load, null, "Не удалось загрузить агентов");
const agents = usePagedResource(load, null, t("ai.could_not_load_agents"));
async function toggleAi(card: AgentCard) {
await setAgentAiActive(card.id, card.aiStatus !== "ACTIVE").catch(() => undefined);
@@ -149,30 +150,30 @@ export function AgentsPage({
<div className="agents-page">
<header className="agents-header">
<div>
<h2>Агенты</h2>
<p>Агент отвечает клиентам первым и собирает диалоги в группу</p>
<h2>{t("common.agents")}</h2>
<p>{t("ai.agent_answers_customers_first_collects")}</p>
</div>
<Button variant="primary" className="agents-create" icon="plus" iconSize={15} onClick={() => setCreating(true)}>Создать агента</Button>
<Button variant="primary" className="agents-create" icon="plus" iconSize={15} onClick={() => setCreating(true)}>{t("profile.create_agent")}</Button>
</header>
{agents.errorText && <div className="agents-error">{agents.errorText}</div>}
{agents.total === 0 ? (
<div className="agents-empty">
<span><Icon name="robot" size={24} strokeWidth={1.8} /></span>
<div>
<strong>Агент отвечает клиентам первым. Создайте первого</strong>
<p>Имя и группа — всё, что нужно для старта. Инструкции, знания и подключения настраиваются потом на карточке.</p>
<strong>{t("ai.agent_answers_customers_first_create")}</strong>
<p>{t("ai.name_group_all_takes_start")}</p>
</div>
<Button variant="primary" className="agents-create is-wide" onClick={() => setCreating(true)}>Создать агента</Button>
<Button variant="primary" className="agents-create is-wide" onClick={() => setCreating(true)}>{t("profile.create_agent")}</Button>
</div>
) : (
<div className="agents-table">
<div className="agents-thead">
<span>Агент</span>
<span>Группа</span>
<span>Статус</span>
<span>Модель</span>
<span>Подключения</span>
<span className="is-right">Открытые</span>
<span>{t("common.agent")}</span>
<span>{t("common.group")}</span>
<span>{t("common.status")}</span>
<span>{t("common.model")}</span>
<span>{t("common.connections")}</span>
<span className="is-right">{t("ai.open")}</span>
<span />
</div>
{agents.items.map((card) => (
Loaded 100 of 286 files, more files were not shown because too many files have changed in this diff. Show more