Закрыты задачи бэклога по неочевидному поведению 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
@@ -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()