Закрыты задачи бэклога по неочевидному поведению UI при ошибках отправки сообщений и блокировках со стороны Message-safety + добалено ограничение на размер сообщения

This commit is contained in:
mi
2026-07-29 16:45:19 +03:00
parent 41e19005fb
commit bda3ff39d7
36 changed files with 486 additions and 141 deletions
+11 -12
View File
@@ -15,33 +15,32 @@
14. На главном экране две кнопки: чат и звонок оператору. На кнопке с чатом уведомление при наличии непрочитанных сообщений. 14. На главном экране две кнопки: чат и звонок оператору. На кнопке с чатом уведомление при наличии непрочитанных сообщений.
# Закрыто 28.07-03.08 # Закрыто 28.07-03.08
19. Подключить OTLP-провайдер
1. Отправлять на UI информацию разные ошибки при попытках авторизации в зависимости от события: код неверен, истёк или уже использован; превышен лимит попыток авторизации, попробуйте через 24 часа (в случаях превышения otp.phone.max_send_attempts_per_24h); превышен лимит неуспешных авторизаций, начните процедуру заново (в случае превышения otp.phone.max_verify_attempts).
23. При отрицательном результате проверки сообщения через message-safety, если сообщение отправлялось с главного экрана, то пользователь не переводится в чат, ему под окном главного экрана выпадает сообщение об ошибке. Не на всех устройствах это видно. Воспринимается как UX-дефект. Как надо: вне зависимости от решения message-safety, если пользователь отправил сообщение, то он переводится на экран с чатом. Далее, сейчас отрицательный результат message-safety выводится пользователю как техническая ошибка (красным цветом под полем ввода сообщения) и опять же воспринимается не как бизнес-логика, а как техническая ошибка. Это поведение нужно поменять. Если сообщение пользователя не прошло проверку, нужно ему в окне чата прислать ответ: Для сообщений: К сожалению, ваше сообщение не соответствует правилам данного чата и не может быть отправлено. Попробуйте переформулировать. Для документов: К сожалению, ваш документ не прошел проверку и не может быть доставлен.
8. UX-дефект: frontend показывает «Не удалось завершить вход» при ошибке отправки отложенного сообщения, хотя вход завершён. Это следует исправить: завершать экран авторизации после bootstrap, а ошибку Bitrix показывать уже в чате (если сообщение отклонено сервисом message-safety, учесть реализацию предыдущего пункта)
22. Ограничить кол-во символов в сообщении на фронте. Показывать в моменте счетчик: n/max, где n сколько символов уже напечатано, max сколько может быть отправлено. Максимальное кол-во символов - положить в app_settings.
7. Убрать с экрана ввода номера телефона тексты согласий внизу экрана: Нажимая «Получить код», вы соглашаетесь с условиями использования и политикой конфиденциальности. Согласия пользователь дает ранее на отдельном экране.
# В разработку: # В разработку:
1. Отправлять на UI информацию разные ошибки при попытках авторизации в зависимости от события: код неверен, истёк или уже использован, превышен лимит попыток авторизации, попробуйте через 24 часа (в случаях превышения otp.phone.max_send_attempts_per_24h), превышен лимит неуспешных авторизаций, начните процедуру заново (в случае превышения otp.phone.max_verify_attempts).
2. После интеграции с смс провайдером, реализовать debounce механизм при авторизации - каждая след. смс можно отправить через все большее окно. 2. После интеграции с смс провайдером, реализовать debounce механизм при авторизации - каждая след. смс можно отправить через все большее окно.
3. аудит безопасности вм
3. На экране профиля в гостевом режиме добавить кнопку "Авторизоваться" 3. На экране профиля в гостевом режиме добавить кнопку "Авторизоваться"
4. При выходе из профиля надо бы сбрасывать cookies Keycloack (Классический OIDC front-channel logout (redirect на end-session → браузер сам сбрасывает cookies Keycloak)) 5. Store-review вход: точечный bypass в Keycloak OTP SPI по номеру из `.env` (`STORE_REVIEW_ENABLED` / `STORE_REVIEW_PHONE` / `STORE_REVIEW_OTP`) — для этого телефона SMS не шлётся, verify принимает фиксированный OTP; остальные номера идут обычным OTP/SMS. Не путать с глобальным `KEYCLOAK_OTP_MOCK_*`. Учётные данные только в Review Notes стора (не в бинарнике/UI); пользователь с демо-контентом; в production включать только на время ревью.
5. Сделать тестового пользователя с фиксированным СМС-входом
6. Хранить историю устройств, с которых пользователь входил в ЛК (Ид юзера, идентификатор устройства, дата последнего входа, способ входа - веб\приложение) 6. Хранить историю устройств, с которых пользователь входил в ЛК (Ид юзера, идентификатор устройства, дата последнего входа, способ входа - веб\приложение)
7. Убрать с экрана при запросе OTP тексты согласий (внизу экрана)
8. UX-дефект: frontend показывает «Не удалось завершить вход» при ошибке отправки отложенного сообщения, хотя вход завершён. Это следует исправить: завершать экран авторизации после bootstrap, а ошибку Bitrix показывать уже в чате.
9. Веб-пуши для PWA 9. Веб-пуши для PWA
10. На кнопке Чат отображать значок наличия непрочитанных уведомлений. Требуется синхронизация между устройствами (решение, например через Dialog.client_last_opened_at) 10. На кнопке Чат отображать значок наличия непрочитанных уведомлений. Требуется синхронизация между устройствами (решение, например через Dialog.client_last_opened_at)
11. Поменять функционал карточке: сейчас слайдер, нужна карусель со стрелками (либо какое-то комбо - подобрать в фигма.) 12. Описание бизнес сущностей: Пользователь
12. Описание бизнес сущностей 12. Описание бизнес сущностей: Сообщение
13. Вынести за пределы ВМ1 сервисы message-safety и sync-service. 13. Вынести за пределы ВМ1 сервисы message-safety и sync-service.
14. Сделать страницу с инстркцией по установке приложения 14. Сделать страницу с инстркцией по установке приложения
15. Написать пользовательское соглашение. 15. Написать пользовательское соглашение.
16. Разработка message-safety 16. Разработка message-safety
17. Разработка sync-service 17. Разработка sync-service
18. Разработка notification-service 18. Разработка notification-service
19. Подключить OTLP-провайдер
20. Поднять второй контур для продакшн 20. Поднять второй контур для продакшн
21. Спрятать сеть за балансировщиком нагрузки 21. Спрятать сеть за балансировщиком нагрузки
22. Ограничить кол-во символов в сообщении на фронте
23. При отрицательном результате проверки сообщения - выдавать пользователю корректную ошибку. Если сообщение отправлялось с главного экрана, то надо направить в чат, и там показать ошибку.
24. Добавить логи (Для Python-сервисов добавить OTLP Log Exporter: api-backend; sms-service; sms-worker. Подключить LoggerProvider, BatchLogRecordProcessor и bounded queue. Передавать resource attributes: service.name; service.version; deployment.environment; service.namespace=han-chat.) Экспортировать структурированные поля request_id, trace_id, span_id, severity и event name. Оставить stdout как аварийный локальный журнал. Добавить canary-тесты, запрещающие экспорт токенов, cookie, телефонов, email, текстов сообщений, SQL и object keys.). 24. Добавить логи (Для Python-сервисов добавить OTLP Log Exporter: api-backend; sms-service; sms-worker. Подключить LoggerProvider, BatchLogRecordProcessor и bounded queue. Передавать resource attributes: service.name; service.version; deployment.environment; service.namespace=han-chat.) Экспортировать структурированные поля request_id, trace_id, span_id, severity и event name. Оставить stdout как аварийный локальный журнал. Добавить canary-тесты, запрещающие экспорт токенов, cookie, телефонов, email, текстов сообщений, SQL и object keys.).
25. Nginx metrics/tracing в signoz 25. Nginx metrics/tracing в signoz
@@ -0,0 +1,33 @@
"""Seed configurable maximum chat message length.
Revision ID: 0009_chat_message_max
Revises: 0008_notifications_v1
Create Date: 2026-07-29
"""
from collections.abc import Sequence
from alembic import op
revision: str = "0009_chat_message_max"
down_revision: str | None = "0008_notifications_v1"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.execute(
"""
INSERT INTO han_app.app_settings
(setting_key, setting_value, value_type, is_public, description,
record_status, updated_at)
VALUES
('chat.message.max_length', '4000', 'integer', true,
'Maximum normalized client chat message length', 'A', now())
ON CONFLICT (setting_key) DO NOTHING
"""
)
def downgrade() -> None:
raise RuntimeError("Chat message maximum length migration is forward-only")
@@ -0,0 +1,21 @@
from collections.abc import Mapping
CHAT_MESSAGE_MAX_LENGTH_KEY = "chat.message.max_length"
CHAT_MESSAGE_TRANSPORT_MAX_LENGTH = 10_000
def validate_chat_settings(values: Mapping[str, str]) -> None:
raw = values.get(CHAT_MESSAGE_MAX_LENGTH_KEY)
if raw is None:
return
try:
value = int(raw)
except (TypeError, ValueError) as error:
raise ValueError(f"{CHAT_MESSAGE_MAX_LENGTH_KEY}: integer value expected") from error
if str(value) != raw:
raise ValueError(f"{CHAT_MESSAGE_MAX_LENGTH_KEY}: canonical integer value expected")
if not 1 <= value <= CHAT_MESSAGE_TRANSPORT_MAX_LENGTH:
raise ValueError(
f"{CHAT_MESSAGE_MAX_LENGTH_KEY}: value must be between 1 "
f"and {CHAT_MESSAGE_TRANSPORT_MAX_LENGTH}"
)
@@ -9,6 +9,7 @@ import yaml
from sqlalchemy import func, or_ from sqlalchemy import func, or_
from sqlalchemy.dialects.postgresql import insert from sqlalchemy.dialects.postgresql import insert
from app.chat_settings import CHAT_MESSAGE_MAX_LENGTH_KEY, validate_chat_settings
from app.db import AppSetting, Database from app.db import AppSetting, Database
from app.otp_settings import OTP_SETTING_KEYS, validate_otp_settings from app.otp_settings import OTP_SETTING_KEYS, validate_otp_settings
from app.settings import get_settings from app.settings import get_settings
@@ -35,10 +36,14 @@ def load_seed(path: Path) -> list[dict[str, Any]]:
raise ValueError(f"{key}: unsupported type {value_type!r}") raise ValueError(f"{key}: unsupported type {value_type!r}")
if key in OTP_SETTING_KEYS and value_type != "integer": if key in OTP_SETTING_KEYS and value_type != "integer":
raise ValueError(f"{key}: type must be integer") raise ValueError(f"{key}: type must be integer")
if key == CHAT_MESSAGE_MAX_LENGTH_KEY and value_type != "integer":
raise ValueError(f"{key}: type must be integer")
if not isinstance(raw.get("public"), bool): if not isinstance(raw.get("public"), bool):
raise ValueError(f"{key}: public must be a boolean") raise ValueError(f"{key}: public must be a boolean")
if key in OTP_SETTING_KEYS and raw["public"]: if key in OTP_SETTING_KEYS and raw["public"]:
raise ValueError(f"{key}: OTP setting must not be public") raise ValueError(f"{key}: OTP setting must not be public")
if key == CHAT_MESSAGE_MAX_LENGTH_KEY and not raw["public"]:
raise ValueError(f"{key}: setting must be public")
description = raw.get("description") description = raw.get("description")
if description is not None and not isinstance(description, str): if description is not None and not isinstance(description, str):
raise ValueError(f"{key}: description must be a string") raise ValueError(f"{key}: description must be a string")
@@ -53,6 +58,7 @@ def load_seed(path: Path) -> list[dict[str, Any]]:
} }
) )
validate_otp_settings({row["setting_key"]: row["setting_value"] for row in rows}) validate_otp_settings({row["setting_key"]: row["setting_value"] for row in rows})
validate_chat_settings({row["setting_key"]: row["setting_value"] for row in rows})
return rows return rows
+8 -1
View File
@@ -156,6 +156,9 @@ async def lifespan(app: FastAPI):
telemetry.shutdown() telemetry.shutdown()
EXPECTED_API_DB_REVISION = "0009_chat_message_max"
app = FastAPI( app = FastAPI(
title="HAN Chat API", title="HAN Chat API",
version="1.0.0", version="1.0.0",
@@ -442,7 +445,7 @@ async def ready(request: Request, db: Session):
try: try:
await db.execute(text("SELECT 1")) await db.execute(text("SELECT 1"))
revision = await db.scalar(text("SELECT version_num FROM han_app.alembic_version LIMIT 1")) revision = await db.scalar(text("SELECT version_num FROM han_app.alembic_version LIMIT 1"))
if revision != "0008_notifications_v1": if revision != EXPECTED_API_DB_REVISION:
raise RuntimeError("unexpected database revision") raise RuntimeError("unexpected database revision")
await load_settings(db) await load_settings(db)
components["postgres"] = "ok" components["postgres"] = "ok"
@@ -501,6 +504,9 @@ async def app_config(request: Request, response: Response, settings: SnapshotDep
"password_enabled": settings.boolean("auth.password.enabled"), "password_enabled": settings.boolean("auth.password.enabled"),
}, },
"operator": {"call_phone": values["operator.call.phone"]}, "operator": {"call_phone": values["operator.call.phone"]},
"messages": {
"max_text_length": settings.integer("chat.message.max_length"),
},
"consents": { "consents": {
"personal_data": { "personal_data": {
"required": settings.boolean("consent.personal_data.required"), "required": settings.boolean("consent.personal_data.required"),
@@ -882,6 +888,7 @@ async def message_create(
body, body,
key, key,
context, context,
business,
request.app.state.settings, request.app.state.settings,
request.app.state.safety, request.app.state.safety,
request.app.state.openlines, request.app.state.openlines,
@@ -341,6 +341,7 @@ async def cta(
TextMessageRequest(content_kind="text", text=item.chat_message_text or ""), TextMessageRequest(content_kind="text", text=item.chat_message_text or ""),
f"notification-message:{item.id}", f"notification-message:{item.id}",
audit_context, audit_context,
settings,
request.app.state.settings, request.app.state.settings,
request.app.state.safety, request.app.state.safety,
request.app.state.openlines, request.app.state.openlines,
+3 -1
View File
@@ -9,6 +9,8 @@ from typing import Annotated, Literal
from pydantic import BaseModel, ConfigDict, Field, HttpUrl, model_validator from pydantic import BaseModel, ConfigDict, Field, HttpUrl, model_validator
from app.chat_settings import CHAT_MESSAGE_TRANSPORT_MAX_LENGTH
class StrictModel(BaseModel): class StrictModel(BaseModel):
model_config = ConfigDict(extra="forbid") model_config = ConfigDict(extra="forbid")
@@ -58,7 +60,7 @@ class SessionStartRequest(StrictModel):
class TextMessageRequest(StrictModel): class TextMessageRequest(StrictModel):
content_kind: Literal["text"] content_kind: Literal["text"]
text: str = Field(min_length=1, max_length=4000) text: str = Field(min_length=1, max_length=CHAT_MESSAGE_TRANSPORT_MAX_LENGTH)
class FileMessageRequest(StrictModel): class FileMessageRequest(StrictModel):
+38 -1
View File
@@ -14,6 +14,7 @@ from sqlalchemy.dialects.postgresql import insert
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.auth import Principal from app.auth import Principal
from app.chat_settings import CHAT_MESSAGE_MAX_LENGTH_KEY, validate_chat_settings
from app.db import ( from app.db import (
AppSetting, AppSetting,
AuditEvent, AuditEvent,
@@ -52,6 +53,26 @@ from app.schemas import (
) )
from app.settings import Settings from app.settings import Settings
MESSAGE_SAFETY_REPLIES = {
"text": (
"К сожалению, ваше сообщение не соответствует правилам данного чата "
"и не может быть отправлено. Попробуйте переформулировать."
),
"file": "К сожалению, ваш документ не прошел проверку и не может быть доставлен.",
}
def safety_reply_message(dialog_id: uuid.UUID, content_kind: str) -> Message:
return Message(
dialog_id=dialog_id,
sender_type="company",
content_kind="text",
text=MESSAGE_SAFETY_REPLIES[content_kind],
safety_status="allowed",
delivery_status="delivered",
occurred_at=datetime.now(UTC),
)
class DomainError(Exception): class DomainError(Exception):
def __init__(self, code: str, status: int, message: str, details: dict[str, Any] | None = None): def __init__(self, code: str, status: int, message: str, details: dict[str, Any] | None = None):
@@ -100,6 +121,7 @@ REQUIRED_SETTINGS = {
"rate_limit.notifications_action.per_user", "rate_limit.notifications_action.per_user",
"rate_limit.notification_upload.per_user", "rate_limit.notification_upload.per_user",
"rate_limit.notifications_public.per_ip", "rate_limit.notifications_public.per_ip",
CHAT_MESSAGE_MAX_LENGTH_KEY,
} | OTP_SETTING_KEYS } | OTP_SETTING_KEYS
@@ -168,11 +190,12 @@ async def load_settings(session: AsyncSession) -> SettingsSnapshot:
f"OTP settings must have integer type and be private: {invalid_metadata}" f"OTP settings must have integer type and be private: {invalid_metadata}"
) )
validate_otp_settings(values) validate_otp_settings(values)
validate_chat_settings(values)
except ValueError as error: except ValueError as error:
raise DomainError( raise DomainError(
"dependency_unavailable", "dependency_unavailable",
503, 503,
"OTP settings are invalid", "Application settings are invalid",
{"reason": str(error)}, {"reason": str(error)},
) from error ) from error
version = hashlib.sha256(json.dumps(values, sort_keys=True).encode()).hexdigest()[:24] version = hashlib.sha256(json.dumps(values, sort_keys=True).encode()).hexdigest()[:24]
@@ -749,6 +772,7 @@ async def send_message(
body: MessageRequest, body: MessageRequest,
idem_key: str, idem_key: str,
context: AuditContext, context: AuditContext,
business: SettingsSnapshot,
settings: Settings, settings: Settings,
safety: SafetyClient, safety: SafetyClient,
openlines: OpenLinesClient, openlines: OpenLinesClient,
@@ -823,6 +847,14 @@ async def send_message(
text, kind = "", "file" text, kind = "", "file"
else: else:
text, kind = unicodedata.normalize("NFKC", body.text).strip(), "text" text, kind = unicodedata.normalize("NFKC", body.text).strip(), "text"
max_length = business.integer(CHAT_MESSAGE_MAX_LENGTH_KEY)
if len(text) > max_length:
raise DomainError(
"message_too_long",
422,
"Message exceeds the configured maximum length",
{"max_length": max_length},
)
message = Message( message = Message(
id=message_id, id=message_id,
dialog_id=dialog_id, dialog_id=dialog_id,
@@ -891,6 +923,9 @@ async def send_message(
message.text = "" message.text = ""
message.safety_status = "blocked" message.safety_status = "blocked"
message.delivery_status = "rejected" message.delivery_status = "rejected"
reply = safety_reply_message(dialog_id, kind)
session.add(reply)
dialog.last_message_at = reply.occurred_at
if attachment and attachment.quarantine_object_key: if attachment and attachment.quarantine_object_key:
attachment.scan_status = "infected" attachment.scan_status = "infected"
await s3.delete_quarantine(attachment.quarantine_object_key) await s3.delete_quarantine(attachment.quarantine_object_key)
@@ -915,6 +950,8 @@ async def send_message(
} }
await session.commit() await session.commit()
await publish_message_status(fanout, message, settings) await publish_message_status(fanout, message, settings)
await publish_message(fanout, reply, settings)
await publish_dialog_status(fanout, dialog)
raise DomainError("message_blocked", 422, "Message was blocked by safety policy") raise DomainError("message_blocked", 422, "Message was blocked by safety policy")
if verdict["_status"] != 200: if verdict["_status"] != 200:
raise DependencyFailure() raise DependencyFailure()
+14 -2
View File
@@ -20,7 +20,19 @@ paths:
get: get:
operationId: getPublicAppConfig operationId: getPublicAppConfig
responses: responses:
"200": {description: Public application configuration} "200":
description: Public application configuration
content:
application/json:
schema:
type: object
required: [messages]
properties:
messages:
type: object
required: [max_text_length]
properties:
max_text_length: {type: integer, minimum: 1, maximum: 10000}
/api/v1/public/content: /api/v1/public/content:
get: get:
operationId: getPublicContent operationId: getPublicContent
@@ -540,7 +552,7 @@ components:
required: [content_kind, text] required: [content_kind, text]
properties: properties:
content_kind: {const: text} content_kind: {const: text}
text: {type: string, minLength: 1, maxLength: 4000} text: {type: string, minLength: 1, maxLength: 10000}
FileMessageRequest: FileMessageRequest:
type: object type: object
additionalProperties: false additionalProperties: false
@@ -4,9 +4,11 @@ from pathlib import Path
from types import SimpleNamespace from types import SimpleNamespace
import yaml import yaml
from alembic.config import Config
from alembic.script import ScriptDirectory
from pydantic import SecretStr from pydantic import SecretStr
from app.main import app, otp_settings, websocket_token from app.main import EXPECTED_API_DB_REVISION, app, otp_settings, websocket_token
from app.services import SettingsSnapshot from app.services import SettingsSnapshot
EXPECTED_PATHS = { EXPECTED_PATHS = {
@@ -58,6 +60,11 @@ def test_openapi_31_contains_all_http_contracts() -> None:
assert committed["paths"].keys() == schema["paths"].keys() assert committed["paths"].keys() == schema["paths"].keys()
def test_readiness_expected_revision_matches_alembic_head() -> None:
scripts = ScriptDirectory.from_config(Config("alembic.ini"))
assert EXPECTED_API_DB_REVISION == scripts.get_current_head()
def test_websocket_route_is_registered() -> None: def test_websocket_route_is_registered() -> None:
assert any(getattr(route, "path", None) == "/api/v1/realtime" for route in app.routes) assert any(getattr(route, "path", None) == "/api/v1/realtime" for route in app.routes)
@@ -104,6 +111,15 @@ def test_committed_openapi_server_does_not_double_api_prefix() -> None:
assert committed["servers"] == [{"url": "/"}] assert committed["servers"] == [{"url": "/"}]
def test_public_config_contract_exposes_message_length() -> None:
committed = yaml.safe_load(Path("openapi.yaml").read_text(encoding="utf-8"))
response = committed["paths"]["/api/v1/public/app-config"]["get"]["responses"]["200"]
messages = response["content"]["application/json"]["schema"]["properties"]["messages"]
assert messages["required"] == ["max_text_length"]
assert messages["properties"]["max_text_length"]["maximum"] == 10_000
def test_otp_settings_contract_is_strict_and_complete() -> None: def test_otp_settings_contract_is_strict_and_complete() -> None:
generated = app.openapi() generated = app.openapi()
response = generated["paths"]["/internal/settings/v1/otp"]["get"]["responses"]["200"] response = generated["paths"]["/internal/settings/v1/otp"]["get"]["responses"]["200"]
@@ -16,6 +16,7 @@ def test_production_like_seed_contains_all_mandatory_settings() -> None:
assert values["otp.phone.code_length"] == "6" assert values["otp.phone.code_length"] == "6"
assert values["otp.phone.ttl_seconds"] == "60" assert values["otp.phone.ttl_seconds"] == "60"
assert values["otp.phone.sms_order_timeout_ms"] == "3000" assert values["otp.phone.sms_order_timeout_ms"] == "3000"
assert values["chat.message.max_length"] == "4000"
def test_seed_rejects_invalid_typed_value(tmp_path: Path) -> None: def test_seed_rejects_invalid_typed_value(tmp_path: Path) -> None:
@@ -62,3 +63,16 @@ def test_seed_rejects_public_otp_setting(tmp_path: Path) -> None:
with pytest.raises(ValueError, match="must not be public"): with pytest.raises(ValueError, match="must not be public"):
load_seed(path) load_seed(path)
@pytest.mark.parametrize("value", [0, 10001])
def test_seed_rejects_invalid_chat_message_max_length(tmp_path: Path, value: int) -> None:
path = tmp_path / "settings.yaml"
path.write_text(
"schema_version: 1\nsettings:\n"
f" chat.message.max_length: {{type: integer, value: {value}, public: true}}\n",
encoding="utf-8",
)
with pytest.raises(ValueError, match="value must be between 1 and 10000"):
load_seed(path)
@@ -14,6 +14,7 @@ from app.schemas import (
decode_cursor, decode_cursor,
encode_cursor, encode_cursor,
) )
from app.services import MESSAGE_SAFETY_REPLIES, safety_reply_message
def test_asyncpg_receives_libpq_dsn_without_sqlalchemy_driver() -> None: def test_asyncpg_receives_libpq_dsn_without_sqlalchemy_driver() -> None:
@@ -51,6 +52,19 @@ def test_message_discriminated_union() -> None:
adapter.validate_python( adapter.validate_python(
{"content_kind": "text", "text": "", "attachment_id": str(uuid.uuid4())} {"content_kind": "text", "text": "", "attachment_id": str(uuid.uuid4())}
) )
longest = adapter.validate_python({"content_kind": "text", "text": "а" * 10_000})
assert len(longest.text) == 10_000
with pytest.raises(ValidationError):
adapter.validate_python({"content_kind": "text", "text": "а" * 10_001})
def test_message_safety_business_replies_are_content_specific() -> None:
assert "переформулировать" in MESSAGE_SAFETY_REPLIES["text"]
assert "документ" in MESSAGE_SAFETY_REPLIES["file"]
reply = safety_reply_message(uuid.uuid4(), "text")
assert reply.sender_type == "company"
assert reply.safety_status == "allowed"
assert reply.delivery_status == "delivered"
def test_fingerprint_is_canonical_and_user_scoped() -> None: def test_fingerprint_is_canonical_and_user_scoped() -> None:
@@ -19,6 +19,7 @@ settings:
consent.marketing.required: {type: boolean, value: false, public: true} consent.marketing.required: {type: boolean, value: false, public: true}
consent.marketing.document_url: {type: string, value: "https://www.han0107.ru/privacy/ads-agree", public: true} consent.marketing.document_url: {type: string, value: "https://www.han0107.ru/privacy/ads-agree", public: true}
consent.marketing.version: {type: string, value: "2026-06-10", public: true} consent.marketing.version: {type: string, value: "2026-06-10", public: true}
chat.message.max_length: {type: integer, value: 4000, public: true}
chat.attachments.allowed_extensions: {type: string_list, value: "jpg,jpeg,png,webp,heic,heif,pdf", public: true} chat.attachments.allowed_extensions: {type: string_list, value: "jpg,jpeg,png,webp,heic,heif,pdf", public: true}
chat.attachments.allowed_mime_types: {type: string_list, value: "image/jpeg,image/png,image/webp,image/heic,image/heif,application/pdf", public: true} chat.attachments.allowed_mime_types: {type: string_list, value: "image/jpeg,image/png,image/webp,image/heic,image/heif,application/pdf", public: true}
chat.attachments.disallowed_extensions: {type: string_list, value: "svg,doc,docx,xls,xlsx,csv", public: false} chat.attachments.disallowed_extensions: {type: string_list, value: "svg,doc,docx,xls,xlsx,csv", public: false}
@@ -4,8 +4,7 @@ import { Text, View } from "react-native";
import { useApp } from "../../src/app-context"; import { useApp } from "../../src/app-context";
import { AuthLoadingView } from "../../src/components/AuthLoadingView"; import { AuthLoadingView } from "../../src/components/AuthLoadingView";
import { ScreenShell } from "../../src/components/ScreenShell"; import { ScreenShell } from "../../src/components/ScreenShell";
import { clearPendingTextIntent, loadPendingTextIntent } from "../../src/pending-intent"; import { loadPendingTextIntent } from "../../src/pending-intent";
import { dialogApi } from "../../src/services";
import { spacing } from "../../src/theme"; import { spacing } from "../../src/theme";
import type { Consents } from "../../src/types"; import type { Consents } from "../../src/types";
import { Button, ErrorNotice, styles } from "../../src/ui"; import { Button, ErrorNotice, styles } from "../../src/ui";
@@ -24,18 +23,14 @@ export default function AuthCallbackScreen() {
setError(undefined); setError(undefined);
const consents = JSON.parse(raw) as Consents; const consents = JSON.parse(raw) as Consents;
await app.finishCallback(params.code, params.state, consents); await app.finishCallback(params.code, params.state, consents);
window.sessionStorage.removeItem("han.pending-consents");
const intent = loadPendingTextIntent(); const intent = loadPendingTextIntent();
if (intent) { if (intent) {
const dialog = await dialogApi.create(intent.dialogKey); router.replace("/dialogs?pending=1");
await dialogApi.sendText(dialog.dialog_id, intent.text, intent.messageKey);
clearPendingTextIntent();
window.sessionStorage.removeItem("han.pending-consents");
router.replace(`/dialogs/${dialog.dialog_id}`);
return; return;
} }
window.sessionStorage.removeItem("han.pending-consents");
router.replace("/"); router.replace("/");
}; };
@@ -7,6 +7,14 @@ import { ChatInputBar } from "../../src/components/ChatInputBar";
import { ChatScreenHeader } from "../../src/components/ChatScreenHeader"; import { ChatScreenHeader } from "../../src/components/ChatScreenHeader";
import { MessageBubble } from "../../src/components/MessageBubble"; import { MessageBubble } from "../../src/components/MessageBubble";
import { ScreenShell } from "../../src/components/ScreenShell"; import { ScreenShell } from "../../src/components/ScreenShell";
import { isMessageBlockedError } from "../../src/api";
import {
clearPendingFileIntent,
clearPendingTextIntent,
loadPendingFileIntent,
loadPendingTextIntent,
} from "../../src/pending-intent";
import { DEFAULT_MESSAGE_MAX_LENGTH, normalizeMessageText } from "../../src/message-text";
import { RealtimeClient, reconcileMessages } from "../../src/realtime"; import { RealtimeClient, reconcileMessages } from "../../src/realtime";
import { dialogApi, profileApi, publicApi, uploadAttachment } from "../../src/services"; import { dialogApi, profileApi, publicApi, uploadAttachment } from "../../src/services";
import type { Message } from "../../src/types"; import type { Message } from "../../src/types";
@@ -29,6 +37,8 @@ export default function ChatScreen() {
const [text, setText] = useState(""); const [text, setText] = useState("");
const [error, setError] = useState<unknown>(); const [error, setError] = useState<unknown>();
const [sending, setSending] = useState(false); const [sending, setSending] = useState(false);
const [retryPending, setRetryPending] = useState<(() => void) | undefined>();
const pendingStarted = useRef<string | undefined>(undefined);
const config = useQuery({ queryKey: ["public-config"], queryFn: publicApi.config }); const config = useQuery({ queryKey: ["public-config"], queryFn: publicApi.config });
const dialog = useQuery({ queryKey: ["dialog", dialogId], queryFn: () => dialogApi.get(dialogId), enabled: Boolean(dialogId) && app.authStatus === "authenticated" }); const dialog = useQuery({ queryKey: ["dialog", dialogId], queryFn: () => dialogApi.get(dialogId), enabled: Boolean(dialogId) && app.authStatus === "authenticated" });
const messages = useQuery({ queryKey: ["messages", dialogId], queryFn: () => dialogApi.messages(dialogId), enabled: Boolean(dialogId) && app.authStatus === "authenticated" }); const messages = useQuery({ queryKey: ["messages", dialogId], queryFn: () => dialogApi.messages(dialogId), enabled: Boolean(dialogId) && app.authStatus === "authenticated" });
@@ -66,14 +76,32 @@ export default function ChatScreen() {
if (items.length) listRef.current?.scrollToEnd({ animated: true }); if (items.length) listRef.current?.scrollToEnd({ animated: true });
}, [messages.data?.items.length]); }, [messages.data?.items.length]);
const sendText = async () => { const handleSendError = async (reason: unknown) => {
const normalized = text.trim(); if (isMessageBlockedError(reason)) {
setError(undefined);
await messages.refetch();
return true;
}
setError(reason);
return false;
};
const sendText = async (value = text, messageKey = crypto.randomUUID()) => {
const normalized = normalizeMessageText(value);
if (!normalized || !dialogId) return; if (!normalized || !dialogId) return;
const maxLength = config.data?.messages?.max_text_length ?? DEFAULT_MESSAGE_MAX_LENGTH;
if (normalized.length > maxLength) {
setError(new Error(`Сообщение слишком длинное. Максимум — ${maxLength} символов.`));
return false;
}
setSending(true); setError(undefined); setSending(true); setError(undefined);
try { try {
const message = await dialogApi.sendText(dialogId, normalized, crypto.randomUUID()); const message = await dialogApi.sendText(dialogId, normalized, messageKey);
merge([message]); setText(""); merge([message]); setText("");
} catch (reason) { setError(reason); } return true;
} catch (reason) {
return await handleSendError(reason);
}
finally { setSending(false); } finally { setSending(false); }
}; };
@@ -89,7 +117,7 @@ export default function ChatScreen() {
input.click(); input.click();
}; };
const sendFile = async (file: File) => { const sendFile = async (file: File, messageKey = crypto.randomUUID()) => {
const limits = config.data?.attachments; const limits = config.data?.attachments;
const max = (limits?.max_size_mb ?? 5) * 1024 * 1024; const max = (limits?.max_size_mb ?? 5) * 1024 * 1024;
const allowed = limits?.allowed_mime_types ?? ["image/jpeg", "image/png", "image/webp", "application/pdf"]; const allowed = limits?.allowed_mime_types ?? ["image/jpeg", "image/png", "image/webp", "application/pdf"];
@@ -100,12 +128,45 @@ export default function ChatScreen() {
setSending(true); setError(undefined); setSending(true); setError(undefined);
try { try {
const uploaded = await uploadAttachment(dialogId, file); const uploaded = await uploadAttachment(dialogId, file);
const message = await dialogApi.sendFile(dialogId, uploaded.attachmentId, uploaded.checksum, crypto.randomUUID()); const message = await dialogApi.sendFile(dialogId, uploaded.attachmentId, uploaded.checksum, messageKey);
merge([message]); merge([message]);
} catch (reason) { setError(reason); } return true;
} catch (reason) {
return await handleSendError(reason);
}
finally { setSending(false); } finally { setSending(false); }
}; };
useEffect(() => {
if (!dialogId || app.authStatus !== "authenticated") return;
const textIntent = loadPendingTextIntent();
const fileIntent = loadPendingFileIntent(dialogId);
const intentKey = textIntent?.dialogId === dialogId
? textIntent.messageKey
: fileIntent?.messageKey;
if (!intentKey || pendingStarted.current === intentKey) return;
pendingStarted.current = intentKey;
const submit = async () => {
const completed = textIntent?.dialogId === dialogId
? await sendText(textIntent.text, textIntent.messageKey)
: fileIntent
? await sendFile(fileIntent.file, fileIntent.messageKey)
: true;
if (completed) {
clearPendingTextIntent();
clearPendingFileIntent();
setRetryPending(undefined);
return;
}
setRetryPending(() => () => {
setRetryPending(undefined);
void submit();
});
};
void submit();
}, [dialogId, app.authStatus]);
const getAttachmentUrl = useCallback(async (attachmentId: string) => { const getAttachmentUrl = useCallback(async (attachmentId: string) => {
const result = await profileApi.attachmentUrl(dialogId, attachmentId); const result = await profileApi.attachmentUrl(dialogId, attachmentId);
return result.download_url; return result.download_url;
@@ -170,10 +231,11 @@ export default function ChatScreen() {
placeholder="Напишите сообщение..." placeholder="Напишите сообщение..."
sending={sending} sending={sending}
value={text} value={text}
maxLength={config.data?.messages?.max_text_length ?? DEFAULT_MESSAGE_MAX_LENGTH}
/> />
{error && ( {error && (
<View style={{ paddingHorizontal: spacing.lg, paddingBottom: spacing.sm }}> <View style={{ paddingHorizontal: spacing.lg, paddingBottom: spacing.sm }}>
<ErrorNotice error={error} /> <ErrorNotice error={error} retry={retryPending} />
</View> </View>
)} )}
</> </>
@@ -5,6 +5,12 @@ import { ScrollView, Text, View } from "react-native";
import { useApp } from "../../src/app-context"; import { useApp } from "../../src/app-context";
import { AppHeader } from "../../src/components/AppHeader"; import { AppHeader } from "../../src/components/AppHeader";
import { ScreenShell } from "../../src/components/ScreenShell"; import { ScreenShell } from "../../src/components/ScreenShell";
import {
bindPendingFileIntent,
bindPendingTextIntent,
loadPendingTextIntent,
pendingDialogKey,
} from "../../src/pending-intent";
import { dialogApi } from "../../src/services"; import { dialogApi } from "../../src/services";
import { ErrorNotice, Loading, styles } from "../../src/ui"; import { ErrorNotice, Loading, styles } from "../../src/ui";
import { spacing } from "../../src/theme"; import { spacing } from "../../src/theme";
@@ -12,7 +18,7 @@ import { spacing } from "../../src/theme";
export default function DialogsScreen() { export default function DialogsScreen() {
const app = useApp(); const app = useApp();
const router = useRouter(); const router = useRouter();
const requestKey = useRef(crypto.randomUUID()); const requestKey = useRef(pendingDialogKey() ?? crypto.randomUUID());
const chat = useQuery({ const chat = useQuery({
queryKey: ["current-dialog", requestKey.current], queryKey: ["current-dialog", requestKey.current],
queryFn: () => dialogApi.create(requestKey.current), queryFn: () => dialogApi.create(requestKey.current),
@@ -20,7 +26,12 @@ export default function DialogsScreen() {
}); });
useEffect(() => { useEffect(() => {
if (chat.data) router.replace(`/dialogs/${chat.data.dialog_id}`); if (chat.data) {
const intent = loadPendingTextIntent();
if (intent && !intent.dialogId) bindPendingTextIntent(intent, chat.data.dialog_id);
bindPendingFileIntent(chat.data.dialog_id);
router.replace(`/dialogs/${chat.data.dialog_id}`);
}
}, [chat.data, router]); }, [chat.data, router]);
if (app.authStatus !== "authenticated") { if (app.authStatus !== "authenticated") {
@@ -15,9 +15,11 @@ import {
clearPendingTextIntent, clearPendingTextIntent,
createPendingTextIntent, createPendingTextIntent,
savePendingTextIntent, savePendingTextIntent,
savePendingFileIntent,
type PendingTextIntent, type PendingTextIntent,
} from "../src/pending-intent"; } from "../src/pending-intent";
import { dialogApi, publicApi, uploadAttachment } from "../src/services"; import { DEFAULT_MESSAGE_MAX_LENGTH, normalizeMessageText } from "../src/message-text";
import { publicApi } from "../src/services";
import type { Consents } from "../src/types"; import type { Consents } from "../src/types";
import { ErrorNotice, Loading, styles } from "../src/ui"; import { ErrorNotice, Loading, styles } from "../src/ui";
@@ -29,7 +31,7 @@ export default function HomeScreen() {
const [pending, setPending] = useState<PendingTextIntent | null>(null); const [pending, setPending] = useState<PendingTextIntent | null>(null);
const [message, setMessage] = useState(""); const [message, setMessage] = useState("");
const [sendError, setSendError] = useState<unknown>(); const [sendError, setSendError] = useState<unknown>();
const [sending, setSending] = useState(false); const [sending] = useState(false);
const [afterNotificationAuth, setAfterNotificationAuth] = useState<(() => Promise<void>) | undefined>(); const [afterNotificationAuth, setAfterNotificationAuth] = useState<(() => Promise<void>) | undefined>();
const { authorize: authorizeParam } = useLocalSearchParams<{ authorize?: string }>(); const { authorize: authorizeParam } = useLocalSearchParams<{ authorize?: string }>();
const handledAuthorizeParam = useRef(false); const handledAuthorizeParam = useRef(false);
@@ -42,28 +44,20 @@ export default function HomeScreen() {
} }
}, [authStatus, authorizeParam]); }, [authStatus, authorizeParam]);
const sendAuthenticated = async (intent: PendingTextIntent) => { const openChatWithText = (intent: PendingTextIntent) => {
setSending(true);
setSendError(undefined); setSendError(undefined);
try { savePendingTextIntent(intent);
const dialog = await dialogApi.create(intent.dialogKey);
await dialogApi.sendText(dialog.dialog_id, intent.text, intent.messageKey);
setMessage(""); setMessage("");
setPending(null); setPending(null);
clearPendingTextIntent(); router.push("/dialogs?pending=1");
router.push(`/dialogs/${dialog.dialog_id}`);
} catch (error) {
setSendError(error);
} finally {
setSending(false);
}
}; };
const send = async (text: string) => { const send = async (text: string) => {
const normalized = text.trim(); const normalized = normalizeMessageText(text);
if (!normalized) return; if (!normalized) return;
if (normalized.length > 4000) { const maxLength = config.data?.messages?.max_text_length ?? DEFAULT_MESSAGE_MAX_LENGTH;
setSendError(new Error("Сообщение слишком длинное. Максимум — 4000 символов.")); if (normalized.length > maxLength) {
setSendError(new Error(`Сообщение слишком длинное. Максимум — ${maxLength} символов.`));
return; return;
} }
const intent = createPendingTextIntent(normalized); const intent = createPendingTextIntent(normalized);
@@ -73,7 +67,7 @@ export default function HomeScreen() {
setConsentOpen(true); setConsentOpen(true);
return; return;
} }
await sendAuthenticated(intent); openChatWithText(intent);
}; };
const chooseFile = () => { const chooseFile = () => {
@@ -103,23 +97,13 @@ export default function HomeScreen() {
setSendError(new Error("Недопустимый тип файла или превышен допустимый размер.")); setSendError(new Error("Недопустимый тип файла или превышен допустимый размер."));
return; return;
} }
setSending(true);
setSendError(undefined); setSendError(undefined);
try { savePendingFileIntent({
const dialog = await dialogApi.create(crypto.randomUUID()); file,
const uploaded = await uploadAttachment(dialog.dialog_id, file); dialogKey: crypto.randomUUID(),
await dialogApi.sendFile( messageKey: crypto.randomUUID(),
dialog.dialog_id, });
uploaded.attachmentId, router.push("/dialogs?pending=1");
uploaded.checksum,
crypto.randomUUID(),
);
router.push(`/dialogs/${dialog.dialog_id}`);
} catch (error) {
setSendError(error);
} finally {
setSending(false);
}
}; };
const accept = async (accepted: { const accept = async (accepted: {
@@ -136,7 +120,7 @@ export default function HomeScreen() {
setConsentOpen(false); setConsentOpen(false);
try { try {
const authorized = await authorize(consents); const authorized = await authorize(consents);
if (authorized && pending) await sendAuthenticated(pending); if (authorized && pending) openChatWithText(pending);
else if (authorized && afterNotificationAuth) await afterNotificationAuth(); else if (authorized && afterNotificationAuth) await afterNotificationAuth();
} catch (error) { } catch (error) {
setSendError(error); setSendError(error);
@@ -181,6 +165,7 @@ export default function HomeScreen() {
onSubmit={() => void send(message)} onSubmit={() => void send(message)}
sending={sending} sending={sending}
value={message} value={message}
maxLength={config.data?.messages?.max_text_length ?? DEFAULT_MESSAGE_MAX_LENGTH}
/> />
<QuickActions <QuickActions
authenticated={authStatus === "authenticated"} authenticated={authStatus === "authenticated"}
@@ -20,6 +20,9 @@ export class ApiError extends Error {
} }
} }
export const isMessageBlockedError = (error: unknown) =>
error instanceof ApiError && error.code === "message_blocked";
export type Diagnostic = { export type Diagnostic = {
at: number; at: number;
method: string; method: string;
@@ -1,6 +1,7 @@
import { Feather } from "@expo/vector-icons"; import { Feather } from "@expo/vector-icons";
import React, { useState } from "react"; import React, { useState } from "react";
import { Pressable, StyleSheet, Text, TextInput, View } from "react-native"; import { Pressable, StyleSheet, Text, TextInput, View } from "react-native";
import { messageFitsLimit } from "../message-text";
import { colors, radii, spacing } from "../theme"; import { colors, radii, spacing } from "../theme";
type Props = { type Props = {
@@ -13,6 +14,7 @@ type Props = {
placeholder?: string; placeholder?: string;
hint?: string; hint?: string;
inputLabel?: string; inputLabel?: string;
maxLength?: number;
}; };
export function ChatInputBar({ export function ChatInputBar({
@@ -25,9 +27,12 @@ export function ChatInputBar({
placeholder = "Напишите ваш вопрос...", placeholder = "Напишите ваш вопрос...",
hint = "Напишите сообщение или прикрепите документ", hint = "Напишите сообщение или прикрепите документ",
inputLabel = "Сообщение", inputLabel = "Сообщение",
maxLength,
}: Props) { }: Props) {
const [focused, setFocused] = useState(false); const [focused, setFocused] = useState(false);
const canSend = Boolean(value.trim()) && !disabled && !sending; const withinLimit = maxLength === undefined || messageFitsLimit(value, maxLength);
const canSend = Boolean(value.trim()) && withinLimit && !disabled && !sending;
const nearLimit = maxLength !== undefined && value.length >= maxLength * 0.9;
return ( return (
<View style={styles.wrapper}> <View style={styles.wrapper}>
@@ -78,6 +83,18 @@ export function ChatInputBar({
</Pressable> </Pressable>
</View> </View>
</View> </View>
{maxLength !== undefined ? (
<Text
accessibilityLiveRegion={withinLimit ? "none" : "polite"}
style={[
styles.counter,
nearLimit && styles.counterWarning,
!withinLimit && styles.counterError,
]}
>
{value.length}/{maxLength}
</Text>
) : null}
{hint ? <Text style={styles.hint}>{hint}</Text> : null} {hint ? <Text style={styles.hint}>{hint}</Text> : null}
</View> </View>
); );
@@ -111,6 +128,9 @@ const styles = StyleSheet.create({
iconButton: { width: 36, height: 36, borderRadius: radii.full, alignItems: "center", justifyContent: "center" }, iconButton: { width: 36, height: 36, borderRadius: radii.full, alignItems: "center", justifyContent: "center" },
sendButton: { width: 36, height: 36, borderRadius: radii.full, backgroundColor: colors.primary, alignItems: "center", justifyContent: "center" }, sendButton: { width: 36, height: 36, borderRadius: radii.full, backgroundColor: colors.primary, alignItems: "center", justifyContent: "center" },
sendButtonDisabled: { opacity: 0.4 }, sendButtonDisabled: { opacity: 0.4 },
counter: { fontSize: 12, color: colors.mutedForeground, textAlign: "right", marginTop: spacing.xs },
counterWarning: { color: colors.warning },
counterError: { color: colors.destructive },
hint: { fontSize: 12, color: colors.mutedForeground, textAlign: "center", marginTop: spacing.sm }, hint: { fontSize: 12, color: colors.mutedForeground, textAlign: "center", marginTop: spacing.sm },
pressed: { opacity: 0.7 }, pressed: { opacity: 0.7 },
}); });
@@ -0,0 +1,9 @@
export const DEFAULT_MESSAGE_MAX_LENGTH = 4000;
export function normalizeMessageText(value: string) {
return value.normalize("NFKC").trim();
}
export function messageFitsLimit(value: string, maxLength: number) {
return normalizeMessageText(value).length <= maxLength;
}
@@ -2,9 +2,19 @@ export type PendingTextIntent = {
text: string; text: string;
dialogKey: string; dialogKey: string;
messageKey: string; messageKey: string;
dialogId?: string;
}; };
const STORAGE_KEY = "han.pending-message"; const STORAGE_KEY = "han.pending-message";
let pendingTextIntent: PendingTextIntent | null = null;
let pendingFileIntent: PendingFileIntent | null = null;
export type PendingFileIntent = {
file: File;
dialogKey: string;
dialogId?: string;
messageKey: string;
};
export function createPendingTextIntent(text: string): PendingTextIntent { export function createPendingTextIntent(text: string): PendingTextIntent {
return { return {
@@ -15,12 +25,14 @@ export function createPendingTextIntent(text: string): PendingTextIntent {
} }
export function savePendingTextIntent(intent: PendingTextIntent) { export function savePendingTextIntent(intent: PendingTextIntent) {
pendingTextIntent = intent;
if (typeof window !== "undefined") { if (typeof window !== "undefined") {
window.sessionStorage.setItem(STORAGE_KEY, JSON.stringify(intent)); window.sessionStorage.setItem(STORAGE_KEY, JSON.stringify(intent));
} }
} }
export function loadPendingTextIntent(): PendingTextIntent | null { export function loadPendingTextIntent(): PendingTextIntent | null {
if (pendingTextIntent) return pendingTextIntent;
if (typeof window === "undefined") return null; if (typeof window === "undefined") return null;
const raw = window.sessionStorage.getItem(STORAGE_KEY); const raw = window.sessionStorage.getItem(STORAGE_KEY);
if (!raw) return null; if (!raw) return null;
@@ -33,18 +45,47 @@ export function loadPendingTextIntent(): PendingTextIntent | null {
) { ) {
return null; return null;
} }
return { pendingTextIntent = {
text: value.text, text: value.text,
dialogKey: value.dialogKey, dialogKey: value.dialogKey,
messageKey: value.messageKey, messageKey: value.messageKey,
...(typeof value.dialogId === "string" ? { dialogId: value.dialogId } : {}),
}; };
return pendingTextIntent;
} catch { } catch {
return null; return null;
} }
} }
export function bindPendingTextIntent(intent: PendingTextIntent, dialogId: string) {
const bound = { ...intent, dialogId };
savePendingTextIntent(bound);
return bound;
}
export function clearPendingTextIntent() { export function clearPendingTextIntent() {
pendingTextIntent = null;
if (typeof window !== "undefined") { if (typeof window !== "undefined") {
window.sessionStorage.removeItem(STORAGE_KEY); window.sessionStorage.removeItem(STORAGE_KEY);
} }
} }
export function savePendingFileIntent(intent: PendingFileIntent) {
pendingFileIntent = intent;
}
export function bindPendingFileIntent(dialogId: string) {
if (pendingFileIntent) pendingFileIntent = { ...pendingFileIntent, dialogId };
}
export function pendingDialogKey() {
return loadPendingTextIntent()?.dialogKey ?? pendingFileIntent?.dialogKey;
}
export function loadPendingFileIntent(dialogId: string) {
return pendingFileIntent?.dialogId === dialogId ? pendingFileIntent : null;
}
export function clearPendingFileIntent() {
pendingFileIntent = null;
}
@@ -61,6 +61,7 @@ export type DocumentItem = {
export type PublicConfig = { export type PublicConfig = {
auth: { phone_enabled: boolean; password_enabled: boolean }; auth: { phone_enabled: boolean; password_enabled: boolean };
operator: { call_phone: string }; operator: { call_phone: string };
messages: { max_text_length: number };
notification?: { notification?: {
carousel_autoplay_enabled?: boolean; carousel_autoplay_enabled?: boolean;
carousel_autoplay_interval_ms?: number; carousel_autoplay_interval_ms?: number;
@@ -23,6 +23,7 @@ test.beforeEach(async ({ page }) => {
json: { json: {
auth: { phone_enabled: true, password_enabled: false }, auth: { phone_enabled: true, password_enabled: false },
operator: { call_phone: "+74950000000" }, operator: { call_phone: "+74950000000" },
messages: { max_text_length: 4000 },
ux: { idle_timeout_minutes: 15 }, ux: { idle_timeout_minutes: 15 },
attachments: { max_size_mb: 5, allowed_extensions: ["png", "pdf"], allowed_mime_types: ["image/png", "application/pdf"] }, attachments: { max_size_mb: 5, allowed_extensions: ["png", "pdf"], allowed_mime_types: ["image/png", "application/pdf"] },
consents: { consents: {
@@ -86,11 +87,11 @@ test("Enter отправляет введённое сообщение", async (
await expect(page.getByRole("heading", { name: "Перед началом работы" })).toBeVisible(); await expect(page.getByRole("heading", { name: "Перед началом работы" })).toBeVisible();
}); });
test("сообщение ограничено 4000 символами", async ({ page }) => { test("счётчик показывает и ограничивает 4000 символов", async ({ page }) => {
await page.goto("/"); await page.goto("/");
await page.getByLabel("Сообщение").fill("а".repeat(4001)); await page.getByLabel("Сообщение").fill("а".repeat(4001));
await page.getByRole("button", { name: "Отправить" }).click(); await expect(page.getByText("4001/4000")).toBeVisible();
await expect(page.getByRole("alert")).toContainText("Максимум — 4000 символов"); await expect(page.getByRole("button", { name: "Отправить" })).toBeDisabled();
await expect(page.getByRole("heading", { name: "Перед началом работы" })).toHaveCount(0); await expect(page.getByRole("heading", { name: "Перед началом работы" })).toHaveCount(0);
}); });
@@ -24,14 +24,21 @@ vi.mock("react-native", () => ({
})); }));
import { buildOidcDeviceMetadata } from "../../src/oidc-device"; import { buildOidcDeviceMetadata } from "../../src/oidc-device";
import { ApiError, isMessageBlockedError } from "../../src/api";
import { messageFitsLimit, normalizeMessageText } from "../../src/message-text";
import { reconcileMessages } from "../../src/reconcile"; import { reconcileMessages } from "../../src/reconcile";
import { sessionMemory } from "../../src/session"; import { sessionMemory } from "../../src/session";
import { SingleFlight } from "../../src/single-flight"; import { SingleFlight } from "../../src/single-flight";
import { websocketJwtProtocol } from "../../src/realtime"; import { websocketJwtProtocol } from "../../src/realtime";
import { import {
clearPendingTextIntent, clearPendingTextIntent,
bindPendingFileIntent,
bindPendingTextIntent,
clearPendingFileIntent,
createPendingTextIntent, createPendingTextIntent,
loadPendingFileIntent,
loadPendingTextIntent, loadPendingTextIntent,
savePendingFileIntent,
savePendingTextIntent, savePendingTextIntent,
} from "../../src/pending-intent"; } from "../../src/pending-intent";
import type { Message } from "../../src/types"; import type { Message } from "../../src/types";
@@ -94,6 +101,33 @@ describe("pending message intent", () => {
expect(loadPendingTextIntent()).toBeNull(); expect(loadPendingTextIntent()).toBeNull();
clearPendingTextIntent(); clearPendingTextIntent();
}); });
it("привязывает отложенный текст и файл к созданному диалогу", () => {
const textIntent = createPendingTextIntent("После входа");
bindPendingTextIntent(textIntent, "dialog-1");
const file = new File(["test"], "test.txt", { type: "text/plain" });
savePendingFileIntent({ file, dialogKey: "file-dialog-key", messageKey: "file-key" });
bindPendingFileIntent("dialog-1");
expect(loadPendingTextIntent()?.dialogId).toBe("dialog-1");
expect(loadPendingFileIntent("dialog-1")?.file).toBe(file);
clearPendingTextIntent();
clearPendingFileIntent();
});
});
describe("message UX rules", () => {
it("нормализует текст и проверяет динамический лимит", () => {
expect(normalizeMessageText(" ")).toBe("A");
expect(messageFitsLimit("1234", 4)).toBe(true);
expect(messageFitsLimit("12345", 4)).toBe(false);
});
it("отличает блокировку message-safety от технической ошибки", () => {
expect(isMessageBlockedError(new ApiError(422, "message_blocked", "blocked"))).toBe(true);
expect(isMessageBlockedError(new ApiError(503, "dependency_unavailable", "failed"))).toBe(false);
});
}); });
describe("SingleFlight", () => { describe("SingleFlight", () => {
@@ -100,27 +100,31 @@ final class OtpStore {
"failure", "order_failed", device); "failure", "order_failed", device);
} }
boolean consume(String challengeId, String suppliedCode, DeviceMetadata device) { VerifyResult consume(String challengeId, String suppliedCode, DeviceMetadata device) {
OtpChallengeEntity challenge = entityManager.find( OtpChallengeEntity challenge = entityManager.find(
OtpChallengeEntity.class, challengeId, LockModeType.PESSIMISTIC_WRITE); OtpChallengeEntity.class, challengeId, LockModeType.PESSIMISTIC_WRITE);
Instant now = Instant.now(); Instant now = Instant.now();
if (challenge == null) return false; if (challenge == null) return VerifyResult.INVALID;
if (!"active".equals(challenge.challengeStatus)) { if (!"active".equals(challenge.challengeStatus)) {
event("otp_verify", challenge.phoneHmac, challengeId, challenge.smsMessageId, event("otp_verify", challenge.phoneHmac, challengeId, challenge.smsMessageId,
"already_used", challenge.challengeStatus, device); "already_used", challenge.challengeStatus, device);
return false; return switch (challenge.challengeStatus) {
case "limited" -> VerifyResult.VERIFY_LIMITED;
case "expired" -> VerifyResult.EXPIRED;
default -> VerifyResult.ALREADY_USED;
};
} }
if (!challenge.expiresAt.isAfter(now)) { if (!challenge.expiresAt.isAfter(now)) {
challenge.challengeStatus = "expired"; challenge.challengeStatus = "expired";
event("otp_verify", challenge.phoneHmac, challengeId, challenge.smsMessageId, event("otp_verify", challenge.phoneHmac, challengeId, challenge.smsMessageId,
"expired", "ttl", device); "expired", "ttl", device);
return false; return VerifyResult.EXPIRED;
} }
if (challenge.verifyAttempts >= challenge.maxVerifyAttempts) { if (challenge.verifyAttempts >= challenge.maxVerifyAttempts) {
challenge.challengeStatus = "limited"; challenge.challengeStatus = "limited";
event("otp_verify", challenge.phoneHmac, challengeId, challenge.smsMessageId, event("otp_verify", challenge.phoneHmac, challengeId, challenge.smsMessageId,
"limited", "attempt_limit", device); "limited", "attempt_limit", device);
return false; return VerifyResult.VERIFY_LIMITED;
} }
challenge.verifyAttempts++; challenge.verifyAttempts++;
boolean valid = suppliedCode != null && Crypto.constantTimeEquals( boolean valid = suppliedCode != null && Crypto.constantTimeEquals(
@@ -130,13 +134,13 @@ final class OtpStore {
if (limited) challenge.challengeStatus = "limited"; if (limited) challenge.challengeStatus = "limited";
event("otp_verify", challenge.phoneHmac, challengeId, challenge.smsMessageId, event("otp_verify", challenge.phoneHmac, challengeId, challenge.smsMessageId,
limited ? "limited" : "failure", limited ? "attempt_limit" : "invalid", device); limited ? "limited" : "failure", limited ? "attempt_limit" : "invalid", device);
return false; return limited ? VerifyResult.VERIFY_LIMITED : VerifyResult.INVALID;
} }
challenge.consumedAt = now; challenge.consumedAt = now;
challenge.challengeStatus = "consumed"; challenge.challengeStatus = "consumed";
event("otp_verify", challenge.phoneHmac, challengeId, challenge.smsMessageId, event("otp_verify", challenge.phoneHmac, challengeId, challenge.smsMessageId,
"success", "verified", device); "success", "verified", device);
return true; return VerifyResult.VERIFIED;
} }
OtpChallengeEntity get(String challengeId) { OtpChallengeEntity get(String challengeId) {
@@ -193,6 +197,14 @@ final class OtpStore {
record Reservation(OtpChallengeEntity challenge, String otp) {} record Reservation(OtpChallengeEntity challenge, String otp) {}
enum VerifyResult {
VERIFIED,
INVALID,
EXPIRED,
ALREADY_USED,
VERIFY_LIMITED
}
static final class OtpLimitException extends RuntimeException { static final class OtpLimitException extends RuntimeException {
OtpLimitException(String message) { super(message); } OtpLimitException(String message) { super(message); }
@@ -66,8 +66,13 @@ public final class PhoneOtpAuthenticator implements Authenticator {
} }
return; return;
} }
if (!new OtpStore(context.getSession()).consume(challengeId, code, device)) { OtpStore.VerifyResult result =
Response response = otpForm(context, PhoneNormalizer.mask(phone), "otpInvalid"); new OtpStore(context.getSession()).consume(challengeId, code, device);
if (result != OtpStore.VerifyResult.VERIFIED) {
String messageKey = result == OtpStore.VerifyResult.VERIFY_LIMITED
? "otpVerifyLimited"
: "otpInvalid";
Response response = otpForm(context, PhoneNormalizer.mask(phone), messageKey);
context.failureChallenge(AuthenticationFlowError.INVALID_CREDENTIALS, response); context.failureChallenge(AuthenticationFlowError.INVALID_CREDENTIALS, response);
return; return;
} }
@@ -28,6 +28,10 @@ class SmsLifecycleContractTest {
void otpThemeUsesSnapshotLengthExpiryAndRealResendAction() throws Exception { void otpThemeUsesSnapshotLengthExpiryAndRealResendAction() throws Exception {
String template = Files.readString(Path.of("themes/han-phone/login/otp.ftl")); String template = Files.readString(Path.of("themes/han-phone/login/otp.ftl"));
String script = Files.readString(Path.of("themes/han-phone/login/resources/js/han-login.js")); String script = Files.readString(Path.of("themes/han-phone/login/resources/js/han-login.js"));
String messages = Files.readString(
Path.of("themes/han-phone/login/messages/messages_ru.properties"));
String verifier = Files.readString(Path.of(
"src/main/java/ru/han/chat/keycloak/PhoneOtpAuthenticator.java"));
assertTrue(template.contains("otpCodeLength")); assertTrue(template.contains("otpCodeLength"));
assertTrue(template.contains("otpExpiresAt")); assertTrue(template.contains("otpExpiresAt"));
@@ -37,6 +41,18 @@ class SmsLifecycleContractTest {
assertTrue(script.contains("han_device_id")); assertTrue(script.contains("han_device_id"));
assertTrue(script.contains("expiresAt - Date.now()")); assertTrue(script.contains("expiresAt - Date.now()"));
assertTrue(script.contains("Number.isFinite(expiresAt)")); assertTrue(script.contains("Number.isFinite(expiresAt)"));
assertTrue(messages.contains("otpVerifyLimited="));
assertTrue(messages.contains("Попробуйте через 24 часа"));
assertTrue(verifier.contains("VerifyResult.VERIFY_LIMITED"));
}
@Test
void phoneThemeDoesNotRepeatConsentText() throws Exception {
String phone = Files.readString(Path.of("themes/han-phone/login/phone.ftl"));
assertTrue(!phone.contains("phoneLegalPrefix"));
assertTrue(!phone.contains("termsOfUse"));
assertTrue(!phone.contains("privacyPolicy"));
} }
@Test @Test
@@ -20,8 +20,9 @@ verifyOtp=Подтвердить
mockMode=Тестовый режим отправки кода mockMode=Тестовый режим отправки кода
phoneInvalid=Проверьте формат номера телефона. phoneInvalid=Проверьте формат номера телефона.
otpInvalid=Код неверен, истёк или уже использован. otpInvalid=Код неверен, истёк или уже использован.
otpVerifyLimited=Превышен лимит неуспешных авторизаций. Начните процедуру заново.
otpCooldown=Повторно отправить СМС можно после обнуления таймера. otpCooldown=Повторно отправить СМС можно после обнуления таймера.
otpLimited=Слишком много попыток. Повторите позже. otpLimited=Превышен лимит попыток авторизации. Попробуйте через 24 часа.
otpUnavailable=Сервис подтверждения временно недоступен. Повторите позже. otpUnavailable=Сервис подтверждения временно недоступен. Повторите позже.
captchaInvalid=Не удалось подтвердить, что запрос отправил человек. Пройдите проверку ещё раз. captchaInvalid=Не удалось подтвердить, что запрос отправил человек. Пройдите проверку ещё раз.
captchaUnavailable=Проверка пока не загрузилась. Проверьте соединение и повторите. captchaUnavailable=Проверка пока не загрузилась. Проверьте соединение и повторите.
@@ -57,12 +57,6 @@
</form> </form>
</div> </div>
<p class="han-legal">
${msg("phoneLegalPrefix")}
<span>${msg("termsOfUse")}</span>
${msg("phoneLegalAnd")}
<span>${msg("privacyPolicy")}</span>
</p>
</div> </div>
<script src="${url.resourcesPath}/js/han-login.js?v=5"></script> <script src="${url.resourcesPath}/js/han-login.js?v=5"></script>
<#if captchaEnabled!false> <#if captchaEnabled!false>
@@ -246,20 +246,6 @@ body.login-pf {
margin-top: 16px; margin-top: 16px;
} }
.han-legal {
margin: 32px 0 0;
color: var(--han-muted);
font-size: 12px;
line-height: 1.5;
text-align: center;
}
.han-legal span {
color: rgba(37, 37, 37, 0.7);
text-decoration: underline;
text-underline-offset: 2px;
}
.han-error { .han-error {
display: flex; display: flex;
align-items: flex-start; align-items: flex-start;
@@ -52,5 +52,13 @@ else
> /etc/nginx/generated/site.conf > /etc/nginx/generated/site.conf
fi fi
cache_root=/var/cache/nginx
cache_public=$cache_root/public
chown root:nginx "$cache_root"
chmod 2770 "$cache_root"
mkdir -p "$cache_public"
chown root:nginx "$cache_public"
chmod 2770 "$cache_public"
nginx -t -c /tmp/nginx.conf nginx -t -c /tmp/nginx.conf
exec nginx -c /tmp/nginx.conf -g 'daemon off;' exec nginx -c /tmp/nginx.conf -g 'daemon off;'
+5 -1
View File
@@ -79,6 +79,7 @@ class InfrastructureConfigTests(unittest.TestCase):
site = (ROOT / "nginx/templates/site-tls.conf.template").read_text(encoding="utf-8") site = (ROOT / "nginx/templates/site-tls.conf.template").read_text(encoding="utf-8")
compose = (ROOT / "nginx/docker-compose.yml").read_text(encoding="utf-8") compose = (ROOT / "nginx/docker-compose.yml").read_text(encoding="utf-8")
config = (ROOT / "nginx/nginx.conf.template").read_text(encoding="utf-8") config = (ROOT / "nginx/nginx.conf.template").read_text(encoding="utf-8")
entrypoint = (ROOT / "nginx/scripts/entrypoint.sh").read_text(encoding="utf-8")
proxy_common = (ROOT / "nginx/snippets/proxy-common.conf").read_text(encoding="utf-8") proxy_common = (ROOT / "nginx/snippets/proxy-common.conf").read_text(encoding="utf-8")
proxy_keycloak = (ROOT / "nginx/snippets/proxy-keycloak.conf").read_text( proxy_keycloak = (ROOT / "nginx/snippets/proxy-keycloak.conf").read_text(
encoding="utf-8" encoding="utf-8"
@@ -93,6 +94,9 @@ class InfrastructureConfigTests(unittest.TestCase):
self.assertIn("uid=0,gid=0", compose) self.assertIn("uid=0,gid=0", compose)
self.assertIn('cap_add: ["CHOWN", "NET_BIND_SERVICE", "SETUID", "SETGID"]', compose) self.assertIn('cap_add: ["CHOWN", "NET_BIND_SERVICE", "SETUID", "SETGID"]', compose)
self.assertTrue(config.startswith("user nginx;\n")) self.assertTrue(config.startswith("user nginx;\n"))
self.assertIn("chown root:nginx \"$cache_root\"", entrypoint)
self.assertIn("chmod 2770 \"$cache_public\"", entrypoint)
self.assertLess(entrypoint.index("cache_root="), entrypoint.index("nginx -t"))
self.assertIn("proxy_read_timeout 30s;", config) self.assertIn("proxy_read_timeout 30s;", config)
self.assertNotIn("proxy_read_timeout", proxy_common) self.assertNotIn("proxy_read_timeout", proxy_common)
self.assertNotIn("proxy_send_timeout", proxy_common) self.assertNotIn("proxy_send_timeout", proxy_common)
@@ -246,7 +250,7 @@ class InfrastructureConfigTests(unittest.TestCase):
self.assertIn("public.digest(", initial) self.assertIn("public.digest(", initial)
self.assertIn("public.digest(", fix) self.assertIn("public.digest(", fix)
self.assertIn('down_revision: str | None = "0001_initial"', fix) self.assertIn('down_revision: str | None = "0001_initial"', fix)
self.assertIn('revision != "0005_otp_settings"', main) self.assertIn('EXPECTED_API_DB_REVISION = "0009_chat_message_max"', main)
def test_consent_audit_migration_supports_existing_and_fresh_databases(self) -> None: def test_consent_audit_migration_supports_existing_and_fresh_databases(self) -> None:
migration = ( migration = (
@@ -73,7 +73,7 @@
| Место | Поведение | | Место | Поведение |
|---|---| |---|---|
| Главная: поле ввода | Отправка текста; без auth → согласия → OTP → отложенная отправка | | Главная: поле ввода | Отправка текста; без auth → согласия → OTP → bootstrap; затем немедленный переход в чат и отложенная отправка |
| Главная: популярные вопросы | Тап = автоотправка текста вопроса (тот же поток, что ручной ввод) | | Главная: популярные вопросы | Тап = автоотправка текста вопроса (тот же поток, что ручной ввод) |
| Кнопка «Чат» | Открывает текущий активный диалог или создаёт его при отсутствии | | Кнопка «Чат» | Открывает текущий активный диалог или создаёт его при отсутствии |
| Кнопка «Оператор» | `tel:` на `operator.call.phone` (не чат-сообщение) | | Кнопка «Оператор» | `tel:` на `operator.call.phone` (не чат-сообщение) |
@@ -184,6 +184,7 @@
| Ключ | Смысл | Seed | | Ключ | Смысл | Seed |
|---|---|---| |---|---|---|
| `chat.message.max_length` | Макс. длина нормализованного текста; public config для счётчика `n/max` | `4000` |
| `chat.attachments.allowed_extensions` | Разрешённые расширения | jpg,jpeg,png,webp,heic,heif,pdf | | `chat.attachments.allowed_extensions` | Разрешённые расширения | jpg,jpeg,png,webp,heic,heif,pdf |
| `chat.attachments.allowed_mime_types` | Разрешённые MIME | image/jpeg, image/png, …, application/pdf | | `chat.attachments.allowed_mime_types` | Разрешённые MIME | image/jpeg, image/png, …, application/pdf |
| `chat.attachments.disallowed_extensions` | Явный deny-list | svg,doc,docx,xls,xlsx,csv | | `chat.attachments.disallowed_extensions` | Явный deny-list | svg,doc,docx,xls,xlsx,csv |
@@ -223,7 +224,7 @@
3. Rate limits (nginx + app). 3. Rate limits (nginx + app).
4. Message Safety (текст, ссылки). 4. Message Safety (текст, ссылки).
5. Allow → outbox → Open Lines → `delivery_status=delivered`, `Dialog``waiting_for_company`. 5. Allow → outbox → Open Lines → `delivery_status=delivered`, `Dialog``waiting_for_company`.
6. Deny → `422 message_blocked`, в Open Lines **не** уходит. 6. Deny → `422 message_blocked`, в Open Lines **не** уходит; backend сохраняет в истории отдельную `company`-реплику с бизнес-текстом для сообщения или документа.
7. Dependency failure → `503`/`504`, при уже созданном Message — `delivery_status=failed`. 7. Dependency failure → `503`/`504`, при уже созданном Message — `delivery_status=failed`.
### 6.3. Исходящий файл ### 6.3. Исходящий файл
+2 -1
View File
@@ -243,6 +243,7 @@ Pydantic `422` преобразуется в `400 validation_error`, чтобы
{ {
"auth": {"phone_enabled": true, "password_enabled": false}, "auth": {"phone_enabled": true, "password_enabled": false},
"operator": {"call_phone": "+74999591007"}, "operator": {"call_phone": "+74999591007"},
"messages": {"max_text_length": 4000},
"consents": { "consents": {
"personal_data": { "personal_data": {
"required": true, "required": true,
@@ -429,7 +430,7 @@ Success `201` возвращает финальный `MessageResponse`:
} }
``` ```
На safety deny — `422 message_blocked`; blocked message допустимо сохранять для аудита, но его текст должен храниться по политике минимизации данных (см. решение M8). На dependency failure — `503/504`; если Message уже создан, его `delivery_status=failed`. На safety deny — `422 message_blocked`; blocked message допустимо сохранять для аудита, но его текст должен храниться по политике минимизации данных (см. решение M8). В той же транзакции backend создаёт отдельную локальную `company`-реплику с безопасным бизнес-текстом для сообщения или документа; эта реплика публикуется в realtime, но не отправляется в Open Lines. На dependency failure — `503/504`; если Message уже создан, его `delivery_status=failed`.
### 6.7. Attachments ### 6.7. Attachments
+7 -5
View File
@@ -55,7 +55,8 @@ frontend-test-site/
| PKCE verifier/state/nonce | session storage, короткий TTL | одноразовые, проверяются callback | | PKCE verifier/state/nonce | session storage, короткий TTL | одноразовые, проверяются callback |
| `ux_session_id`, `last_activity_at` | только память | не localStorage | | `ux_session_id`, `last_activity_at` | только память | не localStorage |
| `guest_session_id` | локально, опционально | не auth, не посылается как право доступа | | `guest_session_id` | локально, опционально | не auth, не посылается как право доступа |
| pending message/file intent | память | восстанавливает отправку после OTP | | pending text intent | session storage, до терминального send outcome | восстанавливает отправку после OIDC redirect |
| pending file intent | память | переносит выбранный `File` с главной в чат без сериализации |
| REST cursors | память по dialog | opaque, не парсить | | REST cursors | память по dialog | opaque, не парсить |
Auth state machine: `guest → authorizing → bootstrapping → authenticated`; при refresh failure — обратно `guest`. UX-сессия независима от Keycloak-сессии. Auth state machine: `guest → authorizing → bootstrapping → authenticated`; при refresh failure — обратно `guest`. UX-сессия независима от Keycloak-сессии.
@@ -106,7 +107,7 @@ Readonly блок «Личные данные» из `GET /me`; блок «До
3. При успехе определить новую UX-сессию (`cold_start` при новом page lifecycle), вызвать `session-start`, затем загрузить profile/dialogs. 3. При успехе определить новую UX-сессию (`cold_start` при новом page lifecycle), вызвать `session-start`, затем загрузить profile/dialogs.
4. При отсутствии/истечении refresh token оставаться guest до protected action. 4. При отсутствии/истечении refresh token оставаться guest до protected action.
5. После OTP callback проверить `state`/`nonce`, обменять code с PKCE, вызвать `POST /auth/bootstrap` с согласиями и device metadata. 5. После OTP callback проверить `state`/`nonce`, обменять code с PKCE, вызвать `POST /auth/bootstrap` с согласиями и device metadata.
6. Создать UX-сессию, если её нет; затем продолжить pending intent. 6. Создать UX-сессию, если её нет, завершить auth-экран и перейти в чат; pending intent отправляется уже экраном чата. Ошибка Message Safety/Bitrix не превращается в ошибку bootstrap.
Bootstrap повторяем безопасно после неопределённого сетевого результата. Телефон в body никогда не передаётся. Bootstrap повторяем безопасно после неопределённого сетевого результата. Телефон в body никогда не передаётся.
@@ -142,10 +143,11 @@ Bootstrap повторяем безопасно после неопределё
1. Валидировать непустой нормализованный текст и клиентский max length из контракта. 1. Валидировать непустой нормализованный текст и клиентский max length из контракта.
2. Если guest — сохранить intent, consent → OTP → bootstrap → session-start. 2. Если guest — сохранить intent, consent → OTP → bootstrap → session-start.
3. `POST /dialogs` с idempotency key, сохранить `dialog_id`. 3. `POST /dialogs` с idempotency key, сохранить `dialog_id` и перейти на экран чата до отправки.
4. `POST /dialogs/{id}/messages` с отдельным key. 4. Экран чата выполняет `POST /dialogs/{id}/messages` с отдельным стабильным key.
5. Блокировать повторный click только для того же intent; другие действия не замораживать. 5. Блокировать повторный click только для того же intent; другие действия не замораживать.
6. На `201` merge `MessageResponse`; на `422 message_blocked` показать безопасный текст без повтора; на `503/504` предложить retry с тем же key. 6. На `201` merge `MessageResponse`; на `422 message_blocked` не показывать красную техническую ошибку, а обновить историю с сохранённой backend `company`-репликой; на `503/504` предложить retry с тем же key.
7. Composer показывает счётчик `n/max`, где `max` приходит как `messages.max_text_length` из public app-config; сверх лимита отправка блокируется без обрезки ввода.
## 11. Файловый flow ## 11. Файловый flow
+14 -24
View File
@@ -18,29 +18,7 @@ mkdir -p ~/.ssh
cp /mnt/c/Users/MI/.ssh/hansel ~/.ssh/hansel cp /mnt/c/Users/MI/.ssh/hansel ~/.ssh/hansel
chmod 600 ~/.ssh/hansel chmod 600 ~/.ssh/hansel
'''bash''' 1. Скопировать и автоматически почистить артефакты
rsync -rltD --no-perms --no-owner --no-group -invc --delete \
--exclude='.env' \
--exclude='*.crt' \
--exclude='*.pem' \
--exclude='*.key' \
--exclude='secrets/' \
-e "ssh -i ~/.ssh/hansel" \
/mnt/c/Users/MI/Documents/Assistent/HAN_chat_specification/codebase/backend/ \
root@135.106.164.58:/opt/han-chat/backend/
-r — рекурсивно.
-l — сохранять символические ссылки.
-t — сохранять время модификации (важно для будущих проверок).
-D — сохранять устройства (на всякий случай, как в -a).
--no-perms --no-owner --no-group — главное исправление: не пытаться копировать права, владельца и группу с Windows на Linux. Это избавит от ложных срабатываний.
-i — покажет только реально измененные файлы (можно заменить на -v, если хотите просто список).
-a (archive) — сохраняет права, время и рекурсивно копирует.
-v (verbose) — выводит список файлов.
-n (dry-run) — главный флаг, показывает, что бы произошло, но не делает этого.
--delete — решение вашей проблемы. Говорит rsync удалять на приемнике (ВМ) файлы, которых нет в источнике (локально).
Важно: не забудьте поставить слэш / в конце пути к локальному проекту, иначе rsync скопирует саму папку внутрь папки на ВМ.
2. Скопировать и автоматически почистить артефакты
Когда вы убедитесь, что вывод предыдущей команды вас устраивает, просто уберите флаг -n: Когда вы убедитесь, что вывод предыдущей команды вас устраивает, просто уберите флаг -n:
rsync -rltD --no-perms --no-owner --no-group -ivc --delete \ rsync -rltD --no-perms --no-owner --no-group -ivc --delete \
--exclude='.env' \ --exclude='.env' \
@@ -58,7 +36,19 @@ rsync -rltD --no-perms --no-owner --no-group -ivc --delete \
/mnt/c/Users/MI/Documents/Assistent/HAN_chat_specification/codebase/backend/ \ /mnt/c/Users/MI/Documents/Assistent/HAN_chat_specification/codebase/backend/ \
root@135.106.164.58:/opt/han-chat/backend/ root@135.106.164.58:/opt/han-chat/backend/
3. Копирование env (опционально) -r — рекурсивно.
-l — сохранять символические ссылки.
-t — сохранять время модификации (важно для будущих проверок).
-D — сохранять устройства (на всякий случай, как в -a).
--no-perms --no-owner --no-group — главное исправление: не пытаться копировать права, владельца и группу с Windows на Linux. Это избавит от ложных срабатываний.
-i — покажет только реально измененные файлы (можно заменить на -v, если хотите просто список).
-a (archive) — сохраняет права, время и рекурсивно копирует.
-v (verbose) — выводит список файлов.
-n (dry-run) — главный флаг, показывает, что бы произошло, но не делает этого.
--delete — решение вашей проблемы. Говорит rsync удалять на приемнике (ВМ) файлы, которых нет в источнике (локально).
Важно: не забудьте поставить слэш / в конце пути к локальному проекту, иначе rsync скопирует саму папку внутрь папки на ВМ.
2. Копирование env (опционально)
scp -i C:\Users\MI\.ssh\hansel -r "C:\Users\MI\Documents\job\HAN_new_life\HANapp\Production\.env" root@135.106.164.58:/opt/han-chat/backend scp -i C:\Users\MI\.ssh\hansel -r "C:\Users\MI\Documents\job\HAN_new_life\HANapp\Production\.env" root@135.106.164.58:/opt/han-chat/backend
cd /opt/han-chat/backend cd /opt/han-chat/backend