Закрыты задачи бэклога по неочевидному поведению 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. На главном экране две кнопки: чат и звонок оператору. На кнопке с чатом уведомление при наличии непрочитанных сообщений.
# Закрыто 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
@@ -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.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
+8 -1
View File
@@ -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,
@@ -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,
+3 -1
View File
@@ -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):
+38 -1
View File
@@ -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()
+14 -2
View File
@@ -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
@@ -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"]
@@ -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)
@@ -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:
@@ -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}
@@ -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("/");
};
@@ -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<unknown>();
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 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 && (
<View style={{ paddingHorizontal: spacing.lg, paddingBottom: spacing.sm }}>
<ErrorNotice error={error} />
<ErrorNotice error={error} retry={retryPending} />
</View>
)}
</>
@@ -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") {
@@ -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<PendingTextIntent | null>(null);
const [message, setMessage] = useState("");
const [sendError, setSendError] = useState<unknown>();
const [sending, setSending] = useState(false);
const [sending] = useState(false);
const [afterNotificationAuth, setAfterNotificationAuth] = useState<(() => Promise<void>) | 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);
savePendingTextIntent(intent);
setMessage("");
setPending(null);
clearPendingTextIntent();
router.push(`/dialogs/${dialog.dialog_id}`);
} catch (error) {
setSendError(error);
} finally {
setSending(false);
}
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}
/>
<QuickActions
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 = {
at: number;
method: string;
@@ -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 (
<View style={styles.wrapper}>
@@ -78,6 +83,18 @@ export function ChatInputBar({
</Pressable>
</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}
</View>
);
@@ -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 },
});
@@ -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;
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;
}
@@ -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;
@@ -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);
});
@@ -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(" ")).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", () => {
@@ -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); }
@@ -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;
}
@@ -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
@@ -20,8 +20,9 @@ verifyOtp=Подтвердить
mockMode=Тестовый режим отправки кода
phoneInvalid=Проверьте формат номера телефона.
otpInvalid=Код неверен, истёк или уже использован.
otpVerifyLimited=Превышен лимит неуспешных авторизаций. Начните процедуру заново.
otpCooldown=Повторно отправить СМС можно после обнуления таймера.
otpLimited=Слишком много попыток. Повторите позже.
otpLimited=Превышен лимит попыток авторизации. Попробуйте через 24 часа.
otpUnavailable=Сервис подтверждения временно недоступен. Повторите позже.
captchaInvalid=Не удалось подтвердить, что запрос отправил человек. Пройдите проверку ещё раз.
captchaUnavailable=Проверка пока не загрузилась. Проверьте соединение и повторите.
@@ -57,12 +57,6 @@
</form>
</div>
<p class="han-legal">
${msg("phoneLegalPrefix")}
<span>${msg("termsOfUse")}</span>
${msg("phoneLegalAnd")}
<span>${msg("privacyPolicy")}</span>
</p>
</div>
<script src="${url.resourcesPath}/js/han-login.js?v=5"></script>
<#if captchaEnabled!false>
@@ -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;
@@ -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;'
+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")
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 = (
@@ -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. Исходящий файл
+2 -1
View File
@@ -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
+7 -5
View File
@@ -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
+14 -24
View File
@@ -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