diff --git a/backlog.md b/backlog.md index 77aa40a..675cb17 100644 --- a/backlog.md +++ b/backlog.md @@ -15,33 +15,32 @@ 14. На главном экране две кнопки: чат и звонок оператору. На кнопке с чатом уведомление при наличии непрочитанных сообщений. # Закрыто 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 механизм при авторизации - каждая след. смс можно отправить через все большее окно. +3. аудит безопасности вм 3. На экране профиля в гостевом режиме добавить кнопку "Авторизоваться" -4. При выходе из профиля надо бы сбрасывать cookies Keycloack (Классический OIDC front-channel logout (redirect на end-session → браузер сам сбрасывает cookies Keycloak)) -5. Сделать тестового пользователя с фиксированным СМС-входом +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 включать только на время ревью. 6. Хранить историю устройств, с которых пользователь входил в ЛК (Ид юзера, идентификатор устройства, дата последнего входа, способ входа - веб\приложение) -7. Убрать с экрана при запросе OTP тексты согласий (внизу экрана) -8. UX-дефект: frontend показывает «Не удалось завершить вход» при ошибке отправки отложенного сообщения, хотя вход завершён. Это следует исправить: завершать экран авторизации после bootstrap, а ошибку Bitrix показывать уже в чате. 9. Веб-пуши для PWA 10. На кнопке Чат отображать значок наличия непрочитанных уведомлений. Требуется синхронизация между устройствами (решение, например через Dialog.client_last_opened_at) -11. Поменять функционал карточке: сейчас слайдер, нужна карусель со стрелками (либо какое-то комбо - подобрать в фигма.) -12. Описание бизнес сущностей +12. Описание бизнес сущностей: Пользователь +12. Описание бизнес сущностей: Сообщение 13. Вынести за пределы ВМ1 сервисы message-safety и sync-service. 14. Сделать страницу с инстркцией по установке приложения 15. Написать пользовательское соглашение. 16. Разработка message-safety 17. Разработка sync-service 18. Разработка notification-service -19. Подключить OTLP-провайдер 20. Поднять второй контур для продакшн 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.). 25. Nginx metrics/tracing в signoz diff --git a/codebase/backend/api-backend/alembic/versions/0009_chat_message_max_length.py b/codebase/backend/api-backend/alembic/versions/0009_chat_message_max_length.py new file mode 100644 index 0000000..62fc848 --- /dev/null +++ b/codebase/backend/api-backend/alembic/versions/0009_chat_message_max_length.py @@ -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") diff --git a/codebase/backend/api-backend/app/chat_settings.py b/codebase/backend/api-backend/app/chat_settings.py new file mode 100644 index 0000000..6f88793 --- /dev/null +++ b/codebase/backend/api-backend/app/chat_settings.py @@ -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}" + ) diff --git a/codebase/backend/api-backend/app/cli/seed_settings.py b/codebase/backend/api-backend/app/cli/seed_settings.py index e3b3909..2d2e592 100644 --- a/codebase/backend/api-backend/app/cli/seed_settings.py +++ b/codebase/backend/api-backend/app/cli/seed_settings.py @@ -9,6 +9,7 @@ import yaml from sqlalchemy import func, or_ 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.otp_settings import OTP_SETTING_KEYS, validate_otp_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}") if key in OTP_SETTING_KEYS and value_type != "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): raise ValueError(f"{key}: public must be a boolean") if key in OTP_SETTING_KEYS and raw["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") if description is not None and not isinstance(description, str): 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_chat_settings({row["setting_key"]: row["setting_value"] for row in rows}) return rows diff --git a/codebase/backend/api-backend/app/main.py b/codebase/backend/api-backend/app/main.py index f40edc6..fa3eb72 100644 --- a/codebase/backend/api-backend/app/main.py +++ b/codebase/backend/api-backend/app/main.py @@ -156,6 +156,9 @@ async def lifespan(app: FastAPI): telemetry.shutdown() +EXPECTED_API_DB_REVISION = "0009_chat_message_max" + + app = FastAPI( title="HAN Chat API", version="1.0.0", @@ -442,7 +445,7 @@ async def ready(request: Request, db: Session): try: await db.execute(text("SELECT 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") await load_settings(db) components["postgres"] = "ok" @@ -501,6 +504,9 @@ async def app_config(request: Request, response: Response, settings: SnapshotDep "password_enabled": settings.boolean("auth.password.enabled"), }, "operator": {"call_phone": values["operator.call.phone"]}, + "messages": { + "max_text_length": settings.integer("chat.message.max_length"), + }, "consents": { "personal_data": { "required": settings.boolean("consent.personal_data.required"), @@ -882,6 +888,7 @@ async def message_create( body, key, context, + business, request.app.state.settings, request.app.state.safety, request.app.state.openlines, diff --git a/codebase/backend/api-backend/app/notification_routes.py b/codebase/backend/api-backend/app/notification_routes.py index f446aed..e433781 100644 --- a/codebase/backend/api-backend/app/notification_routes.py +++ b/codebase/backend/api-backend/app/notification_routes.py @@ -341,6 +341,7 @@ async def cta( TextMessageRequest(content_kind="text", text=item.chat_message_text or ""), f"notification-message:{item.id}", audit_context, + settings, request.app.state.settings, request.app.state.safety, request.app.state.openlines, diff --git a/codebase/backend/api-backend/app/schemas.py b/codebase/backend/api-backend/app/schemas.py index 5a5f1a0..2e7f5b7 100644 --- a/codebase/backend/api-backend/app/schemas.py +++ b/codebase/backend/api-backend/app/schemas.py @@ -9,6 +9,8 @@ from typing import Annotated, Literal from pydantic import BaseModel, ConfigDict, Field, HttpUrl, model_validator +from app.chat_settings import CHAT_MESSAGE_TRANSPORT_MAX_LENGTH + class StrictModel(BaseModel): model_config = ConfigDict(extra="forbid") @@ -58,7 +60,7 @@ class SessionStartRequest(StrictModel): class TextMessageRequest(StrictModel): 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): diff --git a/codebase/backend/api-backend/app/services.py b/codebase/backend/api-backend/app/services.py index dfd1086..d49ea59 100644 --- a/codebase/backend/api-backend/app/services.py +++ b/codebase/backend/api-backend/app/services.py @@ -14,6 +14,7 @@ from sqlalchemy.dialects.postgresql import insert from sqlalchemy.ext.asyncio import AsyncSession from app.auth import Principal +from app.chat_settings import CHAT_MESSAGE_MAX_LENGTH_KEY, validate_chat_settings from app.db import ( AppSetting, AuditEvent, @@ -52,6 +53,26 @@ from app.schemas import ( ) 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): 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.notification_upload.per_user", "rate_limit.notifications_public.per_ip", + CHAT_MESSAGE_MAX_LENGTH_KEY, } | 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}" ) validate_otp_settings(values) + validate_chat_settings(values) except ValueError as error: raise DomainError( "dependency_unavailable", 503, - "OTP settings are invalid", + "Application settings are invalid", {"reason": str(error)}, ) from error version = hashlib.sha256(json.dumps(values, sort_keys=True).encode()).hexdigest()[:24] @@ -749,6 +772,7 @@ async def send_message( body: MessageRequest, idem_key: str, context: AuditContext, + business: SettingsSnapshot, settings: Settings, safety: SafetyClient, openlines: OpenLinesClient, @@ -823,6 +847,14 @@ async def send_message( text, kind = "", "file" else: 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( id=message_id, dialog_id=dialog_id, @@ -891,6 +923,9 @@ async def send_message( message.text = "" message.safety_status = "blocked" 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: attachment.scan_status = "infected" await s3.delete_quarantine(attachment.quarantine_object_key) @@ -915,6 +950,8 @@ async def send_message( } await session.commit() 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") if verdict["_status"] != 200: raise DependencyFailure() diff --git a/codebase/backend/api-backend/openapi.yaml b/codebase/backend/api-backend/openapi.yaml index d7320f3..50197e1 100644 --- a/codebase/backend/api-backend/openapi.yaml +++ b/codebase/backend/api-backend/openapi.yaml @@ -20,7 +20,19 @@ paths: get: operationId: getPublicAppConfig 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: get: operationId: getPublicContent @@ -540,7 +552,7 @@ components: required: [content_kind, text] properties: content_kind: {const: text} - text: {type: string, minLength: 1, maxLength: 4000} + text: {type: string, minLength: 1, maxLength: 10000} FileMessageRequest: type: object additionalProperties: false diff --git a/codebase/backend/api-backend/tests/contract/test_openapi.py b/codebase/backend/api-backend/tests/contract/test_openapi.py index ae1479d..9a742ee 100644 --- a/codebase/backend/api-backend/tests/contract/test_openapi.py +++ b/codebase/backend/api-backend/tests/contract/test_openapi.py @@ -4,9 +4,11 @@ from pathlib import Path from types import SimpleNamespace import yaml +from alembic.config import Config +from alembic.script import ScriptDirectory 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 EXPECTED_PATHS = { @@ -58,6 +60,11 @@ def test_openapi_31_contains_all_http_contracts() -> None: 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: 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": "/"}] +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: generated = app.openapi() response = generated["paths"]["/internal/settings/v1/otp"]["get"]["responses"]["200"] diff --git a/codebase/backend/api-backend/tests/unit/test_cli_settings.py b/codebase/backend/api-backend/tests/unit/test_cli_settings.py index ca59886..eb61d40 100644 --- a/codebase/backend/api-backend/tests/unit/test_cli_settings.py +++ b/codebase/backend/api-backend/tests/unit/test_cli_settings.py @@ -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.ttl_seconds"] == "60" 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: @@ -62,3 +63,16 @@ def test_seed_rejects_public_otp_setting(tmp_path: Path) -> None: with pytest.raises(ValueError, match="must not be public"): 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) diff --git a/codebase/backend/api-backend/tests/unit/test_domain.py b/codebase/backend/api-backend/tests/unit/test_domain.py index bcd8979..99bf24d 100644 --- a/codebase/backend/api-backend/tests/unit/test_domain.py +++ b/codebase/backend/api-backend/tests/unit/test_domain.py @@ -14,6 +14,7 @@ from app.schemas import ( decode_cursor, encode_cursor, ) +from app.services import MESSAGE_SAFETY_REPLIES, safety_reply_message def test_asyncpg_receives_libpq_dsn_without_sqlalchemy_driver() -> None: @@ -51,6 +52,19 @@ def test_message_discriminated_union() -> None: adapter.validate_python( {"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: diff --git a/codebase/backend/deployment/app-settings.production-like.yaml b/codebase/backend/deployment/app-settings.production-like.yaml index 932d12d..576e9e2 100644 --- a/codebase/backend/deployment/app-settings.production-like.yaml +++ b/codebase/backend/deployment/app-settings.production-like.yaml @@ -19,6 +19,7 @@ settings: 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.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_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} diff --git a/codebase/backend/frontend-test-site/app/auth/callback.tsx b/codebase/backend/frontend-test-site/app/auth/callback.tsx index 6bfb77c..9d4e4dc 100644 --- a/codebase/backend/frontend-test-site/app/auth/callback.tsx +++ b/codebase/backend/frontend-test-site/app/auth/callback.tsx @@ -4,8 +4,7 @@ import { Text, View } from "react-native"; import { useApp } from "../../src/app-context"; import { AuthLoadingView } from "../../src/components/AuthLoadingView"; import { ScreenShell } from "../../src/components/ScreenShell"; -import { clearPendingTextIntent, loadPendingTextIntent } from "../../src/pending-intent"; -import { dialogApi } from "../../src/services"; +import { loadPendingTextIntent } from "../../src/pending-intent"; import { spacing } from "../../src/theme"; import type { Consents } from "../../src/types"; import { Button, ErrorNotice, styles } from "../../src/ui"; @@ -24,18 +23,14 @@ export default function AuthCallbackScreen() { setError(undefined); const consents = JSON.parse(raw) as Consents; await app.finishCallback(params.code, params.state, consents); + window.sessionStorage.removeItem("han.pending-consents"); const intent = loadPendingTextIntent(); if (intent) { - const dialog = await dialogApi.create(intent.dialogKey); - await dialogApi.sendText(dialog.dialog_id, intent.text, intent.messageKey); - clearPendingTextIntent(); - window.sessionStorage.removeItem("han.pending-consents"); - router.replace(`/dialogs/${dialog.dialog_id}`); + router.replace("/dialogs?pending=1"); return; } - window.sessionStorage.removeItem("han.pending-consents"); router.replace("/"); }; diff --git a/codebase/backend/frontend-test-site/app/dialogs/[dialogId].tsx b/codebase/backend/frontend-test-site/app/dialogs/[dialogId].tsx index 1ec7f31..b09f888 100644 --- a/codebase/backend/frontend-test-site/app/dialogs/[dialogId].tsx +++ b/codebase/backend/frontend-test-site/app/dialogs/[dialogId].tsx @@ -7,6 +7,14 @@ import { ChatInputBar } from "../../src/components/ChatInputBar"; import { ChatScreenHeader } from "../../src/components/ChatScreenHeader"; import { MessageBubble } from "../../src/components/MessageBubble"; 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 { dialogApi, profileApi, publicApi, uploadAttachment } from "../../src/services"; import type { Message } from "../../src/types"; @@ -29,6 +37,8 @@ export default function ChatScreen() { const [text, setText] = useState(""); const [error, setError] = useState(); const [sending, setSending] = useState(false); + const [retryPending, setRetryPending] = useState<(() => void) | undefined>(); + const pendingStarted = useRef(undefined); 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 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 }); }, [messages.data?.items.length]); - const sendText = async () => { - const normalized = text.trim(); + const handleSendError = async (reason: unknown) => { + 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; + 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); try { - const message = await dialogApi.sendText(dialogId, normalized, crypto.randomUUID()); + const message = await dialogApi.sendText(dialogId, normalized, messageKey); merge([message]); setText(""); - } catch (reason) { setError(reason); } + return true; + } catch (reason) { + return await handleSendError(reason); + } finally { setSending(false); } }; @@ -89,7 +117,7 @@ export default function ChatScreen() { input.click(); }; - const sendFile = async (file: File) => { + const sendFile = async (file: File, messageKey = crypto.randomUUID()) => { const limits = config.data?.attachments; const max = (limits?.max_size_mb ?? 5) * 1024 * 1024; 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); try { 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]); - } catch (reason) { setError(reason); } + return true; + } catch (reason) { + return await handleSendError(reason); + } 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 result = await profileApi.attachmentUrl(dialogId, attachmentId); return result.download_url; @@ -170,10 +231,11 @@ export default function ChatScreen() { placeholder="Напишите сообщение..." sending={sending} value={text} + maxLength={config.data?.messages?.max_text_length ?? DEFAULT_MESSAGE_MAX_LENGTH} /> {error && ( - + )} diff --git a/codebase/backend/frontend-test-site/app/dialogs/index.tsx b/codebase/backend/frontend-test-site/app/dialogs/index.tsx index a3e0fe9..73d14ff 100644 --- a/codebase/backend/frontend-test-site/app/dialogs/index.tsx +++ b/codebase/backend/frontend-test-site/app/dialogs/index.tsx @@ -5,6 +5,12 @@ import { ScrollView, Text, View } from "react-native"; import { useApp } from "../../src/app-context"; import { AppHeader } from "../../src/components/AppHeader"; import { ScreenShell } from "../../src/components/ScreenShell"; +import { + bindPendingFileIntent, + bindPendingTextIntent, + loadPendingTextIntent, + pendingDialogKey, +} from "../../src/pending-intent"; import { dialogApi } from "../../src/services"; import { ErrorNotice, Loading, styles } from "../../src/ui"; import { spacing } from "../../src/theme"; @@ -12,7 +18,7 @@ import { spacing } from "../../src/theme"; export default function DialogsScreen() { const app = useApp(); const router = useRouter(); - const requestKey = useRef(crypto.randomUUID()); + const requestKey = useRef(pendingDialogKey() ?? crypto.randomUUID()); const chat = useQuery({ queryKey: ["current-dialog", requestKey.current], queryFn: () => dialogApi.create(requestKey.current), @@ -20,7 +26,12 @@ export default function DialogsScreen() { }); 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]); if (app.authStatus !== "authenticated") { diff --git a/codebase/backend/frontend-test-site/app/index.tsx b/codebase/backend/frontend-test-site/app/index.tsx index 10cdff6..26d07ce 100644 --- a/codebase/backend/frontend-test-site/app/index.tsx +++ b/codebase/backend/frontend-test-site/app/index.tsx @@ -15,9 +15,11 @@ import { clearPendingTextIntent, createPendingTextIntent, savePendingTextIntent, + savePendingFileIntent, type PendingTextIntent, } 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 { ErrorNotice, Loading, styles } from "../src/ui"; @@ -29,7 +31,7 @@ export default function HomeScreen() { const [pending, setPending] = useState(null); const [message, setMessage] = useState(""); const [sendError, setSendError] = useState(); - const [sending, setSending] = useState(false); + const [sending] = useState(false); const [afterNotificationAuth, setAfterNotificationAuth] = useState<(() => Promise) | undefined>(); const { authorize: authorizeParam } = useLocalSearchParams<{ authorize?: string }>(); const handledAuthorizeParam = useRef(false); @@ -42,28 +44,20 @@ export default function HomeScreen() { } }, [authStatus, authorizeParam]); - const sendAuthenticated = async (intent: PendingTextIntent) => { - setSending(true); + const openChatWithText = (intent: PendingTextIntent) => { setSendError(undefined); - try { - const dialog = await dialogApi.create(intent.dialogKey); - await dialogApi.sendText(dialog.dialog_id, intent.text, intent.messageKey); - setMessage(""); - setPending(null); - clearPendingTextIntent(); - router.push(`/dialogs/${dialog.dialog_id}`); - } catch (error) { - setSendError(error); - } finally { - setSending(false); - } + savePendingTextIntent(intent); + setMessage(""); + setPending(null); + router.push("/dialogs?pending=1"); }; const send = async (text: string) => { - const normalized = text.trim(); + const normalized = normalizeMessageText(text); if (!normalized) return; - if (normalized.length > 4000) { - setSendError(new Error("Сообщение слишком длинное. Максимум — 4000 символов.")); + const maxLength = config.data?.messages?.max_text_length ?? DEFAULT_MESSAGE_MAX_LENGTH; + if (normalized.length > maxLength) { + setSendError(new Error(`Сообщение слишком длинное. Максимум — ${maxLength} символов.`)); return; } const intent = createPendingTextIntent(normalized); @@ -73,7 +67,7 @@ export default function HomeScreen() { setConsentOpen(true); return; } - await sendAuthenticated(intent); + openChatWithText(intent); }; const chooseFile = () => { @@ -103,23 +97,13 @@ export default function HomeScreen() { setSendError(new Error("Недопустимый тип файла или превышен допустимый размер.")); return; } - setSending(true); setSendError(undefined); - try { - const dialog = await dialogApi.create(crypto.randomUUID()); - const uploaded = await uploadAttachment(dialog.dialog_id, file); - await dialogApi.sendFile( - dialog.dialog_id, - uploaded.attachmentId, - uploaded.checksum, - crypto.randomUUID(), - ); - router.push(`/dialogs/${dialog.dialog_id}`); - } catch (error) { - setSendError(error); - } finally { - setSending(false); - } + savePendingFileIntent({ + file, + dialogKey: crypto.randomUUID(), + messageKey: crypto.randomUUID(), + }); + router.push("/dialogs?pending=1"); }; const accept = async (accepted: { @@ -136,7 +120,7 @@ export default function HomeScreen() { setConsentOpen(false); try { const authorized = await authorize(consents); - if (authorized && pending) await sendAuthenticated(pending); + if (authorized && pending) openChatWithText(pending); else if (authorized && afterNotificationAuth) await afterNotificationAuth(); } catch (error) { setSendError(error); @@ -181,6 +165,7 @@ export default function HomeScreen() { onSubmit={() => void send(message)} sending={sending} value={message} + maxLength={config.data?.messages?.max_text_length ?? DEFAULT_MESSAGE_MAX_LENGTH} /> + error instanceof ApiError && error.code === "message_blocked"; + export type Diagnostic = { at: number; method: string; diff --git a/codebase/backend/frontend-test-site/src/components/ChatInputBar.tsx b/codebase/backend/frontend-test-site/src/components/ChatInputBar.tsx index 5a76141..acbdd53 100644 --- a/codebase/backend/frontend-test-site/src/components/ChatInputBar.tsx +++ b/codebase/backend/frontend-test-site/src/components/ChatInputBar.tsx @@ -1,6 +1,7 @@ import { Feather } from "@expo/vector-icons"; import React, { useState } from "react"; import { Pressable, StyleSheet, Text, TextInput, View } from "react-native"; +import { messageFitsLimit } from "../message-text"; import { colors, radii, spacing } from "../theme"; type Props = { @@ -13,6 +14,7 @@ type Props = { placeholder?: string; hint?: string; inputLabel?: string; + maxLength?: number; }; export function ChatInputBar({ @@ -25,9 +27,12 @@ export function ChatInputBar({ placeholder = "Напишите ваш вопрос...", hint = "Напишите сообщение или прикрепите документ", inputLabel = "Сообщение", + maxLength, }: Props) { 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 ( @@ -78,6 +83,18 @@ export function ChatInputBar({ + {maxLength !== undefined ? ( + + {value.length}/{maxLength} + + ) : null} {hint ? {hint} : null} ); @@ -111,6 +128,9 @@ const styles = StyleSheet.create({ 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" }, 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 }, pressed: { opacity: 0.7 }, }); diff --git a/codebase/backend/frontend-test-site/src/message-text.ts b/codebase/backend/frontend-test-site/src/message-text.ts new file mode 100644 index 0000000..2e53bd1 --- /dev/null +++ b/codebase/backend/frontend-test-site/src/message-text.ts @@ -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; +} diff --git a/codebase/backend/frontend-test-site/src/pending-intent.ts b/codebase/backend/frontend-test-site/src/pending-intent.ts index bef0c69..b708c75 100644 --- a/codebase/backend/frontend-test-site/src/pending-intent.ts +++ b/codebase/backend/frontend-test-site/src/pending-intent.ts @@ -2,9 +2,19 @@ export type PendingTextIntent = { text: string; dialogKey: string; messageKey: string; + dialogId?: string; }; 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 { return { @@ -15,12 +25,14 @@ export function createPendingTextIntent(text: string): PendingTextIntent { } export function savePendingTextIntent(intent: PendingTextIntent) { + pendingTextIntent = intent; if (typeof window !== "undefined") { window.sessionStorage.setItem(STORAGE_KEY, JSON.stringify(intent)); } } export function loadPendingTextIntent(): PendingTextIntent | null { + if (pendingTextIntent) return pendingTextIntent; if (typeof window === "undefined") return null; const raw = window.sessionStorage.getItem(STORAGE_KEY); if (!raw) return null; @@ -33,18 +45,47 @@ export function loadPendingTextIntent(): PendingTextIntent | null { ) { return null; } - return { + pendingTextIntent = { text: value.text, dialogKey: value.dialogKey, messageKey: value.messageKey, + ...(typeof value.dialogId === "string" ? { dialogId: value.dialogId } : {}), }; + return pendingTextIntent; } catch { return null; } } +export function bindPendingTextIntent(intent: PendingTextIntent, dialogId: string) { + const bound = { ...intent, dialogId }; + savePendingTextIntent(bound); + return bound; +} + export function clearPendingTextIntent() { + pendingTextIntent = null; if (typeof window !== "undefined") { 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; +} diff --git a/codebase/backend/frontend-test-site/src/types.ts b/codebase/backend/frontend-test-site/src/types.ts index ec209cb..b0c7603 100644 --- a/codebase/backend/frontend-test-site/src/types.ts +++ b/codebase/backend/frontend-test-site/src/types.ts @@ -61,6 +61,7 @@ export type DocumentItem = { export type PublicConfig = { auth: { phone_enabled: boolean; password_enabled: boolean }; operator: { call_phone: string }; + messages: { max_text_length: number }; notification?: { carousel_autoplay_enabled?: boolean; carousel_autoplay_interval_ms?: number; diff --git a/codebase/backend/frontend-test-site/tests/e2e/smoke.spec.ts b/codebase/backend/frontend-test-site/tests/e2e/smoke.spec.ts index 1c33f25..548d126 100644 --- a/codebase/backend/frontend-test-site/tests/e2e/smoke.spec.ts +++ b/codebase/backend/frontend-test-site/tests/e2e/smoke.spec.ts @@ -23,6 +23,7 @@ test.beforeEach(async ({ page }) => { json: { auth: { phone_enabled: true, password_enabled: false }, operator: { call_phone: "+74950000000" }, + messages: { max_text_length: 4000 }, ux: { idle_timeout_minutes: 15 }, attachments: { max_size_mb: 5, allowed_extensions: ["png", "pdf"], allowed_mime_types: ["image/png", "application/pdf"] }, consents: { @@ -86,11 +87,11 @@ test("Enter отправляет введённое сообщение", async ( await expect(page.getByRole("heading", { name: "Перед началом работы" })).toBeVisible(); }); -test("сообщение ограничено 4000 символами", async ({ page }) => { +test("счётчик показывает и ограничивает 4000 символов", async ({ page }) => { await page.goto("/"); await page.getByLabel("Сообщение").fill("а".repeat(4001)); - await page.getByRole("button", { name: "Отправить" }).click(); - await expect(page.getByRole("alert")).toContainText("Максимум — 4000 символов"); + await expect(page.getByText("4001/4000")).toBeVisible(); + await expect(page.getByRole("button", { name: "Отправить" })).toBeDisabled(); await expect(page.getByRole("heading", { name: "Перед началом работы" })).toHaveCount(0); }); diff --git a/codebase/backend/frontend-test-site/tests/unit/core.test.ts b/codebase/backend/frontend-test-site/tests/unit/core.test.ts index 390a8b7..3f3a457 100644 --- a/codebase/backend/frontend-test-site/tests/unit/core.test.ts +++ b/codebase/backend/frontend-test-site/tests/unit/core.test.ts @@ -24,14 +24,21 @@ vi.mock("react-native", () => ({ })); 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 { sessionMemory } from "../../src/session"; import { SingleFlight } from "../../src/single-flight"; import { websocketJwtProtocol } from "../../src/realtime"; import { clearPendingTextIntent, + bindPendingFileIntent, + bindPendingTextIntent, + clearPendingFileIntent, createPendingTextIntent, + loadPendingFileIntent, loadPendingTextIntent, + savePendingFileIntent, savePendingTextIntent, } from "../../src/pending-intent"; import type { Message } from "../../src/types"; @@ -94,6 +101,33 @@ describe("pending message intent", () => { expect(loadPendingTextIntent()).toBeNull(); 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(" A ")).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", () => { diff --git a/codebase/backend/keycloak/src/main/java/ru/han/chat/keycloak/OtpStore.java b/codebase/backend/keycloak/src/main/java/ru/han/chat/keycloak/OtpStore.java index 635da90..57826c1 100644 --- a/codebase/backend/keycloak/src/main/java/ru/han/chat/keycloak/OtpStore.java +++ b/codebase/backend/keycloak/src/main/java/ru/han/chat/keycloak/OtpStore.java @@ -100,27 +100,31 @@ final class OtpStore { "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.class, challengeId, LockModeType.PESSIMISTIC_WRITE); Instant now = Instant.now(); - if (challenge == null) return false; + if (challenge == null) return VerifyResult.INVALID; if (!"active".equals(challenge.challengeStatus)) { event("otp_verify", challenge.phoneHmac, challengeId, challenge.smsMessageId, "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)) { challenge.challengeStatus = "expired"; event("otp_verify", challenge.phoneHmac, challengeId, challenge.smsMessageId, "expired", "ttl", device); - return false; + return VerifyResult.EXPIRED; } if (challenge.verifyAttempts >= challenge.maxVerifyAttempts) { challenge.challengeStatus = "limited"; event("otp_verify", challenge.phoneHmac, challengeId, challenge.smsMessageId, "limited", "attempt_limit", device); - return false; + return VerifyResult.VERIFY_LIMITED; } challenge.verifyAttempts++; boolean valid = suppliedCode != null && Crypto.constantTimeEquals( @@ -130,13 +134,13 @@ final class OtpStore { if (limited) challenge.challengeStatus = "limited"; event("otp_verify", challenge.phoneHmac, challengeId, challenge.smsMessageId, limited ? "limited" : "failure", limited ? "attempt_limit" : "invalid", device); - return false; + return limited ? VerifyResult.VERIFY_LIMITED : VerifyResult.INVALID; } challenge.consumedAt = now; challenge.challengeStatus = "consumed"; event("otp_verify", challenge.phoneHmac, challengeId, challenge.smsMessageId, "success", "verified", device); - return true; + return VerifyResult.VERIFIED; } OtpChallengeEntity get(String challengeId) { @@ -193,6 +197,14 @@ final class OtpStore { record Reservation(OtpChallengeEntity challenge, String otp) {} + enum VerifyResult { + VERIFIED, + INVALID, + EXPIRED, + ALREADY_USED, + VERIFY_LIMITED + } + static final class OtpLimitException extends RuntimeException { OtpLimitException(String message) { super(message); } diff --git a/codebase/backend/keycloak/src/main/java/ru/han/chat/keycloak/PhoneOtpAuthenticator.java b/codebase/backend/keycloak/src/main/java/ru/han/chat/keycloak/PhoneOtpAuthenticator.java index 76342d8..c4163df 100644 --- a/codebase/backend/keycloak/src/main/java/ru/han/chat/keycloak/PhoneOtpAuthenticator.java +++ b/codebase/backend/keycloak/src/main/java/ru/han/chat/keycloak/PhoneOtpAuthenticator.java @@ -66,8 +66,13 @@ public final class PhoneOtpAuthenticator implements Authenticator { } return; } - if (!new OtpStore(context.getSession()).consume(challengeId, code, device)) { - Response response = otpForm(context, PhoneNormalizer.mask(phone), "otpInvalid"); + OtpStore.VerifyResult result = + 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); return; } diff --git a/codebase/backend/keycloak/src/test/java/ru/han/chat/keycloak/SmsLifecycleContractTest.java b/codebase/backend/keycloak/src/test/java/ru/han/chat/keycloak/SmsLifecycleContractTest.java index a94af4c..a7f94c7 100644 --- a/codebase/backend/keycloak/src/test/java/ru/han/chat/keycloak/SmsLifecycleContractTest.java +++ b/codebase/backend/keycloak/src/test/java/ru/han/chat/keycloak/SmsLifecycleContractTest.java @@ -28,6 +28,10 @@ class SmsLifecycleContractTest { void otpThemeUsesSnapshotLengthExpiryAndRealResendAction() throws Exception { 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 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("otpExpiresAt")); @@ -37,6 +41,18 @@ class SmsLifecycleContractTest { assertTrue(script.contains("han_device_id")); assertTrue(script.contains("expiresAt - Date.now()")); 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 diff --git a/codebase/backend/keycloak/themes/han-phone/login/messages/messages_ru.properties b/codebase/backend/keycloak/themes/han-phone/login/messages/messages_ru.properties index b62d525..988f125 100644 --- a/codebase/backend/keycloak/themes/han-phone/login/messages/messages_ru.properties +++ b/codebase/backend/keycloak/themes/han-phone/login/messages/messages_ru.properties @@ -20,8 +20,9 @@ verifyOtp=Подтвердить mockMode=Тестовый режим отправки кода phoneInvalid=Проверьте формат номера телефона. otpInvalid=Код неверен, истёк или уже использован. +otpVerifyLimited=Превышен лимит неуспешных авторизаций. Начните процедуру заново. otpCooldown=Повторно отправить СМС можно после обнуления таймера. -otpLimited=Слишком много попыток. Повторите позже. +otpLimited=Превышен лимит попыток авторизации. Попробуйте через 24 часа. otpUnavailable=Сервис подтверждения временно недоступен. Повторите позже. captchaInvalid=Не удалось подтвердить, что запрос отправил человек. Пройдите проверку ещё раз. captchaUnavailable=Проверка пока не загрузилась. Проверьте соединение и повторите. diff --git a/codebase/backend/keycloak/themes/han-phone/login/phone.ftl b/codebase/backend/keycloak/themes/han-phone/login/phone.ftl index a6088b9..28c126f 100644 --- a/codebase/backend/keycloak/themes/han-phone/login/phone.ftl +++ b/codebase/backend/keycloak/themes/han-phone/login/phone.ftl @@ -57,12 +57,6 @@ - <#if captchaEnabled!false> diff --git a/codebase/backend/keycloak/themes/han-phone/login/resources/css/han-login.css b/codebase/backend/keycloak/themes/han-phone/login/resources/css/han-login.css index 439ee6b..d76d97b 100644 --- a/codebase/backend/keycloak/themes/han-phone/login/resources/css/han-login.css +++ b/codebase/backend/keycloak/themes/han-phone/login/resources/css/han-login.css @@ -246,20 +246,6 @@ body.login-pf { 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 { display: flex; align-items: flex-start; diff --git a/codebase/backend/nginx/scripts/entrypoint.sh b/codebase/backend/nginx/scripts/entrypoint.sh index 7bbbc22..764df44 100644 --- a/codebase/backend/nginx/scripts/entrypoint.sh +++ b/codebase/backend/nginx/scripts/entrypoint.sh @@ -52,5 +52,13 @@ else > /etc/nginx/generated/site.conf 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 exec nginx -c /tmp/nginx.conf -g 'daemon off;' diff --git a/codebase/backend/tests/test_config.py b/codebase/backend/tests/test_config.py index f5d150a..afd469e 100644 --- a/codebase/backend/tests/test_config.py +++ b/codebase/backend/tests/test_config.py @@ -79,6 +79,7 @@ class InfrastructureConfigTests(unittest.TestCase): site = (ROOT / "nginx/templates/site-tls.conf.template").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") + 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_keycloak = (ROOT / "nginx/snippets/proxy-keycloak.conf").read_text( encoding="utf-8" @@ -93,6 +94,9 @@ class InfrastructureConfigTests(unittest.TestCase): self.assertIn("uid=0,gid=0", compose) self.assertIn('cap_add: ["CHOWN", "NET_BIND_SERVICE", "SETUID", "SETGID"]', compose) 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.assertNotIn("proxy_read_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(", 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: migration = ( diff --git a/functional_blocks (business logic)/chat-requirements.md b/functional_blocks (business logic)/chat-requirements.md index c3ba335..6dfde8a 100644 --- a/functional_blocks (business logic)/chat-requirements.md +++ b/functional_blocks (business logic)/chat-requirements.md @@ -73,7 +73,7 @@ | Место | Поведение | |---|---| -| Главная: поле ввода | Отправка текста; без auth → согласия → OTP → отложенная отправка | +| Главная: поле ввода | Отправка текста; без auth → согласия → OTP → bootstrap; затем немедленный переход в чат и отложенная отправка | | Главная: популярные вопросы | Тап = автоотправка текста вопроса (тот же поток, что ручной ввод) | | Кнопка «Чат» | Открывает текущий активный диалог или создаёт его при отсутствии | | Кнопка «Оператор» | `tel:` на `operator.call.phone` (не чат-сообщение) | @@ -184,6 +184,7 @@ | Ключ | Смысл | Seed | |---|---|---| +| `chat.message.max_length` | Макс. длина нормализованного текста; public config для счётчика `n/max` | `4000` | | `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.disallowed_extensions` | Явный deny-list | svg,doc,docx,xls,xlsx,csv | @@ -223,7 +224,7 @@ 3. Rate limits (nginx + app). 4. Message Safety (текст, ссылки). 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`. ### 6.3. Исходящий файл diff --git a/modules/module-01-api-backend.md b/modules/module-01-api-backend.md index 692c53d..98899f9 100644 --- a/modules/module-01-api-backend.md +++ b/modules/module-01-api-backend.md @@ -243,6 +243,7 @@ Pydantic `422` преобразуется в `400 validation_error`, чтобы { "auth": {"phone_enabled": true, "password_enabled": false}, "operator": {"call_phone": "+74999591007"}, + "messages": {"max_text_length": 4000}, "consents": { "personal_data": { "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 diff --git a/modules/module-02-frontend-test-site.md b/modules/module-02-frontend-test-site.md index ff27412..16d1dd6 100644 --- a/modules/module-02-frontend-test-site.md +++ b/modules/module-02-frontend-test-site.md @@ -55,7 +55,8 @@ frontend-test-site/ | PKCE verifier/state/nonce | session storage, короткий TTL | одноразовые, проверяются callback | | `ux_session_id`, `last_activity_at` | только память | не localStorage | | `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, не парсить | 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. 4. При отсутствии/истечении refresh token оставаться guest до protected action. 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 никогда не передаётся. @@ -142,10 +143,11 @@ Bootstrap повторяем безопасно после неопределё 1. Валидировать непустой нормализованный текст и клиентский max length из контракта. 2. Если guest — сохранить intent, consent → OTP → bootstrap → session-start. -3. `POST /dialogs` с idempotency key, сохранить `dialog_id`. -4. `POST /dialogs/{id}/messages` с отдельным key. +3. `POST /dialogs` с idempotency key, сохранить `dialog_id` и перейти на экран чата до отправки. +4. Экран чата выполняет `POST /dialogs/{id}/messages` с отдельным стабильным key. 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 diff --git a/releases/#0 deploy-steps.md b/releases/#0 deploy-steps.md index bb5325c..9a52c91 100644 --- a/releases/#0 deploy-steps.md +++ b/releases/#0 deploy-steps.md @@ -18,29 +18,7 @@ mkdir -p ~/.ssh cp /mnt/c/Users/MI/.ssh/hansel ~/.ssh/hansel chmod 600 ~/.ssh/hansel -'''bash''' -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. Скопировать и автоматически почистить артефакты +1. Скопировать и автоматически почистить артефакты Когда вы убедитесь, что вывод предыдущей команды вас устраивает, просто уберите флаг -n: rsync -rltD --no-perms --no-owner --no-group -ivc --delete \ --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/ \ 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 cd /opt/han-chat/backend