Закрыты задачи бэклога по неочевидному поведению UI при ошибках отправки сообщений и блокировках со стороны Message-safety + добалено ограничение на размер сообщения
This commit is contained in:
+11
-12
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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(" 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", () => {
|
||||
|
||||
@@ -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); }
|
||||
|
||||
|
||||
+7
-2
@@ -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;
|
||||
}
|
||||
|
||||
+16
@@ -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;'
|
||||
|
||||
@@ -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. Исходящий файл
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user