🚚 chore: переименование Chatbolls → Chatballs во всём проекте

Бренд, API-схема, TOTP issuer, письма, тексты бота уведомлений, загрузчик виджета
(window.ChatballsChat, типы postMessage), ключи localStorage, id/параметры
виджета помощи, health-service, README, скрипты, демо (пароль
Chatballs-Demo-2026). Макеты в design/baseline не тронуты — это файлы владельца.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
AndreyandClaude Fable 5.1 committed 2026-09-05 21:43:33 +03:00
1 parent 22e0a12a16
commit dbd7ae3150
28 files changed
+51 -44

No files matched your search

+6 -6
View File
@@ -1,4 +1,4 @@
# Chatbolls
# Chatballs
Canonical implementation workspace for `hub.edevs.tech`.
@@ -10,16 +10,16 @@ Canonical implementation workspace for `hub.edevs.tech`.
Windows (PowerShell):
```powershell
git clone <URL репозитория> chatbolls
cd chatbolls/code/custocrm
git clone <URL репозитория> chatballs
cd chatballs/code/custocrm
.\scripts\start.ps1
```
Linux / macOS:
```bash
git clone <URL репозитория> chatbolls
cd chatbolls/code/custocrm
git clone <URL репозитория> chatballs
cd chatballs/code/custocrm
./scripts/start.sh
```
@@ -45,7 +45,7 @@ cd chatbolls/code/custocrm
Демо ставится в вашу организацию и удаляется целиком одной кнопкой: **Настройки →
Демо-данные**. Там же — учётные записи демо-сотрудников из разных групп, чтобы
посмотреть систему их глазами (пароль общий и намеренно публичный —
`Chatbolls-Demo-2026`). Ваши данные при удалении не затрагиваются: сид ведёт
`Chatballs-Demo-2026`). Ваши данные при удалении не затрагиваются: сид ведёт
реестр созданных записей и удаляет ровно их.
Для разработки то же доступно из командной строки:
+1 -1
View File
@@ -146,7 +146,7 @@ EMAIL_PORT = int(os.environ.get("EMAIL_PORT", "587"))
EMAIL_HOST_USER = os.environ.get("EMAIL_HOST_USER", "")
EMAIL_HOST_PASSWORD = os.environ.get("EMAIL_HOST_PASSWORD", "")
EMAIL_USE_TLS = os.environ.get("EMAIL_USE_TLS", "true").lower() == "true"
DEFAULT_FROM_EMAIL = os.environ.get("DEFAULT_FROM_EMAIL", "Chatbolls <no-reply@edevs.tech>")
DEFAULT_FROM_EMAIL = os.environ.get("DEFAULT_FROM_EMAIL", "Chatballs <no-reply@edevs.tech>")
# Base URL of the internal UI, used to build links inside transactional emails.
INTERNAL_UI_BASE_URL = os.environ.get("INTERNAL_UI_BASE_URL", "http://localhost:5173")
+1 -1
View File
@@ -8,7 +8,7 @@ urlpatterns = [
path("chat-widget.js", WidgetLoaderView.as_view(), name="chat-widget-loader"),
path(
"api/v1/schema/",
get_schema_view(title="Chatbolls API", version="0.1.0"),
get_schema_view(title="Chatballs API", version="0.1.0"),
name="openapi-schema",
),
path("api/v1/auth/", include("hub_platform.identity.auth_urls")),
@@ -1,6 +1,6 @@
"""Демо-провайдер: живой AI без ключей и сети.
Нужен, чтобы человек, развернувший Chatbolls за минуту, увидел агента в
Нужен, чтобы человек, развернувший Chatballs за минуту, увидел агента в
работе до подключения BYOK-провайдера. Отвечает детерминированно по знаниям,
которые runtime положил в системные сообщения («Отвечай только на основе этих
знаний»): выбирает предложения с наибольшим пересечением слов с вопросом.
+2 -2
View File
@@ -29,9 +29,9 @@ class HasCapability(BasePermission):
class CloudDeliveryOnly(BasePermission):
"""Guard tenant APIs that exist only in the managed Chatbolls Cloud."""
"""Guard tenant APIs that exist only in the managed Chatballs Cloud."""
message = "This operation is available only in Chatbolls Cloud"
message = "This operation is available only in Chatballs Cloud"
def has_permission(self, request: Request, view: APIView) -> bool:
return settings.CUS_DELIVERY_MODE == "CLOUD"
+1 -1
View File
@@ -7,7 +7,7 @@ from django.http import JsonResponse
def live(request):
surface = settings.CUS_RUNTIME_SURFACE
return JsonResponse(
{"status": "ok", "service": f"chatbolls-{surface}", "surface": surface}
{"status": "ok", "service": f"chatballs-{surface}", "surface": surface}
)
@@ -8,7 +8,7 @@ import time
from hub_platform.identity.models import HumanUser
TOTP_SESSION_KEY = "identity_pending_totp_user_id"
TOTP_ISSUER = "Chatbolls"
TOTP_ISSUER = "Chatballs"
TOTP_PERIOD_SECONDS = 30
@@ -118,7 +118,7 @@
{
"type": "RELEASE_PUBLISHED",
"level": "SUCCESS",
"title": "Обновление Chatbolls: голосовые сообщения",
"title": "Обновление Chatballs: голосовые сообщения",
"body": "Операторы могут отправлять голосовые в Telegram и MAX, входящие расшифровываются одной кнопкой.",
"targetRoute": "chat",
"audience": "ALL",
@@ -1,7 +1,7 @@
{
"schemaVersion": 1,
"_comment": "Демо «Ателье Норд» (дизайн-базлайн v2, каст кадров A1/A2 и A–H). Ставится в организацию установщика: организация и её владелец здесь не создаются. Пароль всех демо-сотрудников один — см. demoPassword.",
"demoPassword": "Chatbolls-Demo-2026",
"demoPassword": "Chatballs-Demo-2026",
"showcaseAccounts": [
"elena",
"svetlana",
+4 -4
View File
@@ -16,10 +16,10 @@ def _password_setup_url(user: HumanUser) -> str:
def send_initial_access_email(user: HumanUser) -> None:
setup_url = _password_setup_url(user)
send_mail(
subject="Первичный доступ к Chatbolls",
subject="Первичный доступ к Chatballs",
message=(
f"Здравствуйте, {user.full_name or user.email}.\n\n"
"Для вас создана учётная запись Chatbolls. Чтобы задать пароль первичного доступа, "
"Для вас создана учётная запись Chatballs. Чтобы задать пароль первичного доступа, "
"перейдите по ссылке:\n"
f"{setup_url}\n\n"
"Ссылка действует 30 минут. Если вы не ожидали это письмо, обратитесь к владельцу организации."
@@ -32,10 +32,10 @@ def send_initial_access_email(user: HumanUser) -> None:
def send_password_reset_email(user: HumanUser) -> None:
reset_url = _password_setup_url(user)
send_mail(
subject="Восстановление доступа к Chatbolls",
subject="Восстановление доступа к Chatballs",
message=(
f"Здравствуйте, {user.full_name or user.email}.\n\n"
"Вы запросили сброс пароля для Chatbolls. Чтобы задать новый пароль, перейдите по ссылке:\n"
"Вы запросили сброс пароля для Chatballs. Чтобы задать новый пароль, перейдите по ссылке:\n"
f"{reset_url}\n\n"
"Ссылка действует 30 минут. Если вы не запрашивали сброс, просто проигнорируйте это письмо."
),
+1 -1
View File
@@ -115,7 +115,7 @@ def complete_setup(data: SetupInput) -> SetupResult:
# Блокировка от гонки двух вкладок: второй запрос дождётся первого и
# увидит созданную организацию.
with connections[INSTANCE_DB_ALIAS].cursor() as cursor:
cursor.execute("SELECT pg_advisory_xact_lock(hashtext('chatbolls.instance_setup'))")
cursor.execute("SELECT pg_advisory_xact_lock(hashtext('chatballs.instance_setup'))")
if Organization.objects.exists():
raise SetupAlreadyCompleted()
@@ -104,7 +104,7 @@ class DemoDatasetTests(TestCase):
self.assertEqual(memberships.count(), 7)
self.assertEqual(memberships.filter(blocked_at__isnull=False).count(), 1)
anna = HumanUser.objects.get(email="a.kim@atelie-nord.ru")
self.assertTrue(anna.check_password("Chatbolls-Demo-2026"))
self.assertTrue(anna.check_password("Chatballs-Demo-2026"))
self.assertTrue(HumanUser.objects.get(email="k.volkov@atelie-nord.ru").totp_enabled)
# Агенты: активный с AI, черновик без AI, выключенный канал.
@@ -198,7 +198,7 @@ class DemoDatasetApiTests(TestCase):
self.assertEqual([a["email"] for a in accounts], ["e.kuznetsova@atelie-nord.ru", "s.petrova@atelie-nord.ru", "i.saveliev@atelie-nord.ru"])
self.assertEqual(accounts[1]["groups"], ["Операторы"])
self.assertEqual(accounts[2]["groups"], ["Поддержка"])
self.assertTrue(all(a["password"] == "Chatbolls-Demo-2026" for a in accounts))
self.assertTrue(all(a["password"] == "Chatballs-Demo-2026" for a in accounts))
self.assertEqual(self.client.post("/api/v1/company/demo/").status_code, 400)
removing = self.client.delete("/api/v1/company/demo/")
@@ -212,7 +212,7 @@ class DemoDatasetApiTests(TestCase):
self.client.post("/api/v1/company/demo/")
self._dispatch_pending()
employee = APIClient()
employee.login(username="s.petrova@atelie-nord.ru", password="Chatbolls-Demo-2026")
employee.login(username="s.petrova@atelie-nord.ru", password="Chatballs-Demo-2026")
self.assertEqual(employee.post("/api/v1/company/demo/").status_code, 403)
self.assertEqual(employee.delete("/api/v1/company/demo/").status_code, 403)
@@ -24,8 +24,8 @@ logger = logging.getLogger(__name__)
CODE_TTL = timedelta(minutes=10)
NOTIFIER_PURPOSE = "notifications"
CONFIRMATION_TEXT = "Готово! Уведомления Chatbolls подключены. Отключить можно в профиле."
HINT_TEXT = "Это сервисный бот уведомлений Chatbolls. Чтобы подключить уведомления, откройте профиль в Chatbolls и нажмите «Подключить»."
CONFIRMATION_TEXT = "Готово! Уведомления Chatballs подключены. Отключить можно в профиле."
HINT_TEXT = "Это сервисный бот уведомлений Chatballs. Чтобы подключить уведомления, откройте профиль в Chatballs и нажмите «Подключить»."
# База deep-link по провайдеру: и TG, и MAX поддерживают ?start=<код>.
_DEEP_LINK_BASE = {
@@ -46,7 +46,7 @@ def clean_portal_domains(portal) -> None:
)
if portal.custom_domain == portal.hosted_domain:
raise ValidationError(
{"custom_domain": "Свой домен должен отличаться от адреса Chatbolls"}
{"custom_domain": "Свой домен должен отличаться от адреса Chatballs"}
)
collision = portal.__class__.objects.exclude(pk=portal.pk).filter(
models.Q(hosted_domain=portal.custom_domain)
@@ -67,7 +67,7 @@ def verify_custom_domain(portal: SupportPortal) -> SupportPortal:
) from error
if settings.CUS_HELP_PUBLIC_IPV4 not in addresses:
raise ValidationError(
{"customDomain": "A-запись домена указывает не на сервер Chatbolls"}
{"customDomain": "A-запись домена указывает не на сервер Chatballs"}
)
expected = domain_verification_value(portal)
@@ -203,7 +203,7 @@ class SupportPortalManagementTests(SupportPortalTestCase):
)
self.assertEqual(response.status_code, 400, response.content)
self.assertIn("указывает не на сервер Chatbolls", str(response.json()))
self.assertIn("указывает не на сервер Chatballs", str(response.json()))
@override_settings(CUS_APP_PRIMARY_HOSTS=["app.customer.example"])
def test_custom_domain_cannot_shadow_application_host(self) -> None:
+5 -5
View File
@@ -2,7 +2,7 @@
# одним тегом:
# <script src="https://hub.edevs.tech/chat-widget.js"
# data-widget-key="wgt_public_key" async></script>
# Authenticated host регистрирует async token provider через ChatbollsChat.init;
# Authenticated host регистрирует async token provider через ChatballsChat.init;
# Product Support Token никогда не попадает в URL или data-*.
# Лоадер рисует launcher и открывает панель в изолированном iframe (/chat/).
#
@@ -27,7 +27,7 @@ LOADER_JS = r"""
var panelUrl = origin + "/chat/?" + entryQuery + "&instanceId=" + encodeURIComponent(instanceId);
var open = false, frame = null, unread = false, callActive = false, tokenProvider = null;
var api = window.ChatbollsChat = window.ChatbollsChat || {};
var api = window.ChatballsChat = window.ChatballsChat || {};
window.CustoCRMChat = api; // legacy alias для уже встроенных хостов
api._instances = api._instances || [];
api._providers = api._providers || {};
@@ -118,17 +118,17 @@ LOADER_JS = r"""
play(notification);
}
if (d.type === "edevs-chat-activity" && d.kind === "call") setCallActive(Boolean(d.active));
if (d.type === "chatbolls-chat-token-request" && tokenProvider) {
if (d.type === "chatballs-chat-token-request" && tokenProvider) {
Promise.resolve().then(tokenProvider).then(function (token) {
frame.contentWindow.postMessage({
type: "chatbolls-chat-token-response",
type: "chatballs-chat-token-response",
instanceId: instanceId,
requestId: d.requestId,
token: String(token || "")
}, origin);
}).catch(function () {
frame.contentWindow.postMessage({
type: "chatbolls-chat-token-response",
type: "chatballs-chat-token-response",
instanceId: instanceId,
requestId: d.requestId,
token: ""
+1 -1
View File
@@ -1,6 +1,6 @@
import type { AuthenticatedUser, SessionUser } from "../types";
const preferenceKey = "chatbolls.organizationPublicId";
const preferenceKey = "chatballs.organizationPublicId";
export function activateOrganization(
identity: AuthenticatedUser,
@@ -69,7 +69,7 @@ export function OrganizationLogoField({
tone="danger"
icon="trash"
title="Удалить логотип?"
description="Вместо него в меню снова будет показан знак Chatbolls."
description="Вместо него в меню снова будет показан знак Chatballs."
actions={(
<>
<Button variant="secondary" onClick={() => setConfirmingRemoval(false)}>
@@ -94,7 +94,7 @@ export function AuthResetPassword({ onDone }: { onDone: () => void }) {
}
return (
<AuthFrame title="Новый пароль" subtitle="Задайте новый пароль для входа в Chatbolls." logo="shield" width={420} note={backLink}>
<AuthFrame title="Новый пароль" subtitle="Задайте новый пароль для входа в Chatballs." logo="shield" width={420} note={backLink}>
<form className="auth-card" onSubmit={submit}>
{error && <div className="auth-error"><span className="auth-error-dot">!</span><span>{error}</span></div>}
<label className="field-label">Новый пароль</label>
@@ -1,6 +1,6 @@
import { useEffect } from "react";
const SCRIPT_ID = "chatbolls-help-widget-loader";
const SCRIPT_ID = "chatballs-help-widget-loader";
export function PortalWebWidget({ widgetKey }: { widgetKey: string }) {
useEffect(() => {
@@ -11,7 +11,7 @@ describe("supportHref", () => {
supportAvailable: true,
supportWidgetKey: "wgt_foxray_support",
supportChannelCode: "foxray-support",
})).toBe("https://foxray.example/account?from=help&chatbollsSupportWidget=wgt_foxray_support");
})).toBe("https://foxray.example/account?from=help&chatballsSupportWidget=wgt_foxray_support");
});
it("does not build an unauthenticated fallback without a channel", () => {
@@ -7,7 +7,7 @@ export function supportHref(product: HelpProduct): string | null {
if (!product.siteUrl || !product.supportWidgetKey) return null;
try {
const url = new URL(product.siteUrl);
url.searchParams.set("chatbollsSupportWidget", product.supportWidgetKey);
url.searchParams.set("chatballsSupportWidget", product.supportWidgetKey);
return url.toString();
} catch {
return null;
@@ -31,7 +31,7 @@ const STEPS: Step[] = [
];
function storageKey(user: SessionUser): string {
return `chatbolls.launch.${user.organizationPublicId}.hidden`;
return `chatballs.launch.${user.organizationPublicId}.hidden`;
}
function isHidden(user: SessionUser): boolean {
+1 -1
View File
@@ -6,7 +6,7 @@ import { Icon } from "./icons";
// сотрудникам подсказки не показываются, это решает вызывающая сторона).
// Закрытие запоминается на устройстве по ключу `id`.
const STORAGE_PREFIX = "chatbolls.hint.";
const STORAGE_PREFIX = "chatballs.hint.";
function isDismissed(id: string): boolean {
try {
+2 -2
View File
@@ -26,7 +26,7 @@ function requestSupportToken(): Promise<string> {
if (
event.source !== window.parent
|| event.origin !== HOST_ORIGIN
|| data.type !== "chatbolls-chat-token-response"
|| data.type !== "chatballs-chat-token-response"
|| data.instanceId !== INSTANCE_ID
|| data.requestId !== requestId
) return;
@@ -37,7 +37,7 @@ function requestSupportToken(): Promise<string> {
}
window.addEventListener("message", receive);
window.parent.postMessage({
type: "chatbolls-chat-token-request",
type: "chatballs-chat-token-request",
instanceId: INSTANCE_ID,
requestId,
widgetKey: WIDGET_KEY,
+7
View File
@@ -49,6 +49,7 @@
"@edevs/ui": "0.1.0",
"antd": "^5.26.0",
"js-yaml": "^5.2.1",
"lucide": "^1.41.0",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-markdown": "^10.1.0",
@@ -2589,6 +2590,12 @@
"yallist": "^3.0.2"
}
},
"node_modules/lucide": {
"version": "1.41.0",
"resolved": "https://registry.npmjs.org/lucide/-/lucide-1.41.0.tgz",
"integrity": "sha512-fIXP7JzC1vehdqOrMhwpbcXzX6CvUMuoDf16HY1FmC4sDBeuwrH2A0c6QmLn3wnSvaMchpEyM+JWijyTvOs3kw==",
"license": "ISC"
},
"node_modules/magic-string": {
"version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
+1 -1
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env sh
# Локальный запуск Chatbolls (Linux/macOS): canonical compose.yaml + dev override.
# Локальный запуск Chatballs (Linux/macOS): canonical compose.yaml + dev override.
# Параметры не нужны: владельца и демо-данные создаёт мастер первого запуска
# в браузере (http://localhost). Режим поставки: ./scripts/start.sh [cloud|self-hosted]
set -eu