Закрыты задачи бэклога по неочевидному поведению UI при ошибках отправки сообщений и блокировках со стороны Message-safety + добалено ограничение на размер сообщения
This commit is contained in:
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user