Косметические правки по фронту, раскатка приложения в продакшн версии, исправление бага с созданием контакта

This commit is contained in:
mi
2026-09-03 11:34:25 +03:00
parent f989097484
commit 465a70d488
18 changed files with 528 additions and 139 deletions
@@ -117,6 +117,67 @@ async def ensure_delivery_outbox(
return outbox
OPENLINES_FALLBACK_DISPLAY_NAME = "Новый клиент HAN"
def openlines_display_name(full_name: str | None) -> str:
name = (full_name or "").strip()
return name or OPENLINES_FALLBACK_DISPLAY_NAME
def openlines_user_payload(user: UserIdentity, profile: ClientProfile | None) -> dict[str, str]:
return {
"id": str(user.id),
"display_name": openlines_display_name(profile.full_name if profile else None),
"phone": user.phone_number,
}
async def load_active_client_profile(
session: AsyncSession, user_id: uuid.UUID
) -> ClientProfile | None:
return (
await session.execute(
select(ClientProfile).where(
ClientProfile.user_id == user_id, ClientProfile.record_status == "A"
)
)
).scalar_one_or_none()
def openlines_delivery_payload(
*,
message: Message,
dialog_id: uuid.UUID,
user: UserIdentity,
profile: ClientProfile | None,
attachment: MessageAttachment | None,
) -> dict[str, Any]:
files: list[dict[str, Any]] = []
if attachment:
files.append(
{
"attachment_id": str(attachment.id),
"name": attachment.safe_file_name,
"mime_type": attachment.mime_type,
"size_bytes": attachment.size_bytes,
"_storage_bucket": attachment.storage_bucket,
"_object_key": attachment.object_key,
}
)
return {
"message_id": str(message.id),
"external_chat_id": str(dialog_id),
"occurred_at": message.occurred_at.isoformat(),
"user": openlines_user_payload(user, profile),
"message": {
"content_kind": message.content_kind,
"text": message.text,
"files": files,
},
}
class DomainError(Exception):
def __init__(self, code: str, status: int, message: str, details: dict[str, Any] | None = None):
self.code, self.status, self.message = code, status, message
@@ -1032,34 +1093,18 @@ async def send_message(
"bypassed" if verdict["processing_mode"] == "mock" else "clean"
)
message.safety_status = "allowed"
profile = await load_active_client_profile(session, user.id)
outbox = await ensure_delivery_outbox(
session,
message_id=message.id,
external_chat_id=dialog_id,
payload_json={
"message_id": str(message.id),
"external_chat_id": str(dialog_id),
"occurred_at": message.occurred_at.isoformat(),
"user": {"id": str(user.id), "display_name": user.phone_number},
"message": {
"content_kind": message.content_kind,
"text": message.text,
"files": (
[
{
"attachment_id": str(attachment.id),
"name": attachment.safe_file_name,
"mime_type": attachment.mime_type,
"size_bytes": attachment.size_bytes,
"_storage_bucket": attachment.storage_bucket,
"_object_key": attachment.object_key,
}
]
if attachment
else []
),
},
},
payload_json=openlines_delivery_payload(
message=message,
dialog_id=dialog_id,
user=user,
profile=profile,
attachment=attachment,
),
# Keep the row recoverable after a process crash, but do not let the
# delivery worker race the synchronous first attempt.
next_attempt_at=datetime.now(UTC)
@@ -30,7 +30,9 @@ from app.notification_service import expire_notifications
from app.realtime import RealtimeFanout
from app.services import (
ensure_delivery_outbox,
load_active_client_profile,
load_settings,
openlines_delivery_payload,
publish_dialog_status,
publish_message_status,
)
@@ -189,37 +191,18 @@ async def safety_once(
else None
)
if dialog and user:
profile = await load_active_client_profile(session, user.id)
await ensure_delivery_outbox(
session,
message_id=message.id,
external_chat_id=dialog.id,
payload_json={
"message_id": str(message.id),
"external_chat_id": str(dialog.id),
"occurred_at": message.occurred_at.isoformat(),
"user": {
"id": str(user.id),
"display_name": user.phone_number,
},
"message": {
"content_kind": message.content_kind,
"text": message.text,
"files": (
[
{
"attachment_id": str(attachment.id),
"name": attachment.safe_file_name,
"mime_type": attachment.mime_type,
"size_bytes": attachment.size_bytes,
"_storage_bucket": attachment.storage_bucket,
"_object_key": attachment.object_key,
}
]
if attachment
else []
),
},
},
payload_json=openlines_delivery_payload(
message=message,
dialog_id=dialog.id,
user=user,
profile=profile,
attachment=attachment,
),
next_attempt_at=datetime.now(UTC),
)
elif verdict["_status"] == 403 and message:
@@ -304,7 +304,11 @@ async def test_openlines_payload_gets_fresh_download_url_without_storage_fields(
"message_id": str(uuid.uuid4()),
"external_chat_id": str(uuid.uuid4()),
"occurred_at": "2026-07-10T12:00:00+00:00",
"user": {"id": str(uuid.uuid4()), "display_name": "+79990000000"},
"user": {
"id": str(uuid.uuid4()),
"display_name": "Новый клиент HAN",
"phone": "+79990000000",
},
"message": {
"content_kind": "file",
"text": "",
@@ -20,6 +20,7 @@ from app.schemas import (
from app.services import (
MESSAGE_SAFETY_REPLIES,
ensure_delivery_outbox,
openlines_user_payload,
safety_reply_message,
safety_task_recovery_at,
)
@@ -85,6 +86,20 @@ def test_safety_recovery_starts_after_synchronous_polling_window() -> None:
assert (safety_task_recovery_at(now, settings) - now).total_seconds() == 307
def test_openlines_user_payload_uses_profile_name_and_phone() -> None:
user = SimpleNamespace(id=uuid.uuid4(), phone_number="+79991234567")
named = openlines_user_payload(user, SimpleNamespace(full_name="Иван Иванов"))
assert named == {
"id": str(user.id),
"display_name": "Иван Иванов",
"phone": "+79991234567",
}
assert openlines_user_payload(user, SimpleNamespace(full_name=" "))["display_name"] == (
"Новый клиент HAN"
)
assert openlines_user_payload(user, None)["display_name"] == "Новый клиент HAN"
async def test_delivery_outbox_returns_concurrent_insert_winner() -> None:
existing = object()
session = SimpleNamespace(
@@ -114,6 +114,7 @@ class UserDto(BaseModel):
model_config = ConfigDict(extra="forbid")
id: uuid.UUID
display_name: str = Field(min_length=1, max_length=255)
phone: str | None = Field(default=None, min_length=1, max_length=32)
class FileDto(BaseModel):
@@ -697,7 +698,7 @@ def create_app(settings: Settings | None = None) -> FastAPI:
"Idempotency-Key must equal message_id",
),
)
body = dto.model_dump(mode="json")
body = dto.model_dump(mode="json", exclude_none=True)
fp = canonical_fingerprint(body)
async with request.app.state.sessions() as session:
row = await session.scalar(
@@ -992,17 +993,11 @@ def extract_delivery(result: dict[str, Any]) -> tuple[int | None, str | None, st
)
async def deliver_outbound(app: FastAPI, row_id: uuid.UUID) -> dict[str, Any]:
async with app.state.sessions() as session:
row = await session.get(OutboundMessage, row_id)
portal = await active_portal(session)
if not row or not portal:
raise RuntimeError("portal_not_installed")
payload = row.payload_json
def imconnector_send_fields(connector: str, line: str, payload: dict[str, Any]) -> dict[str, Any]:
message = payload["message"]
fields: dict[str, Any] = {
"CONNECTOR": app.state.settings.bitrix_connector_id,
"LINE": app.state.settings.bitrix_open_line_id,
"CONNECTOR": connector,
"LINE": line,
"MESSAGES[0][user][id]": payload["user"]["id"],
"MESSAGES[0][user][name]": payload["user"]["display_name"],
"MESSAGES[0][message][id]": payload["message_id"],
@@ -1010,10 +1005,28 @@ async def deliver_outbound(app: FastAPI, row_id: uuid.UUID) -> dict[str, Any]:
"MESSAGES[0][message][text]": message["text"],
"MESSAGES[0][chat][id]": payload["external_chat_id"],
}
if message["files"]:
phone = payload["user"].get("phone")
if phone:
fields["MESSAGES[0][user][phone]"] = phone
if message.get("files"):
file = message["files"][0]
fields["MESSAGES[0][message][files][0][url]"] = file["download_url"]
fields["MESSAGES[0][message][files][0][name]"] = file["name"]
return fields
async def deliver_outbound(app: FastAPI, row_id: uuid.UUID) -> dict[str, Any]:
async with app.state.sessions() as session:
row = await session.get(OutboundMessage, row_id)
portal = await active_portal(session)
if not row or not portal:
raise RuntimeError("portal_not_installed")
payload = row.payload_json
fields = imconnector_send_fields(
app.state.settings.bitrix_connector_id,
app.state.settings.bitrix_open_line_id,
payload,
)
result = await app.state.bitrix.call(portal, "imconnector.send.messages", fields)
chat_id, session_id, bitrix_message_id = extract_delivery(result)
response = {
@@ -91,6 +91,7 @@ components:
properties:
id: {type: string, format: uuid}
display_name: {type: string, maxLength: 255}
phone: {type: string, minLength: 1, maxLength: 32}
message:
type: object
additionalProperties: false
@@ -23,6 +23,7 @@ from app.main import (
BitrixClient,
TokenCipher,
canonical_fingerprint,
imconnector_send_fields,
normalize_event,
resolve_inbound_file_urls,
retry_delay,
@@ -278,3 +279,23 @@ async def test_bitrix_call_refreshes_and_retries_once_after_401():
assert result == {"ok": True}
assert auth_values == ["old-access", "new-access"]
assert refresh_calls == [False, True]
def test_imconnector_send_fields_maps_name_and_phone():
payload = {
"message_id": "mid",
"external_chat_id": "chat",
"occurred_at": "2026-07-10T09:00:00Z",
"user": {"id": "uid", "display_name": "Иван Иванов", "phone": "+79990000000"},
"message": {"text": "hello", "files": []},
}
fields = imconnector_send_fields("han_mobile_app", "8", payload)
assert fields["MESSAGES[0][user][name]"] == "Иван Иванов"
assert fields["MESSAGES[0][user][phone]"] == "+79990000000"
legacy = {
**payload,
"user": {"id": "uid", "display_name": "+79990000000"},
}
legacy_fields = imconnector_send_fields("han_mobile_app", "8", legacy)
assert "MESSAGES[0][user][phone]" not in legacy_fields
assert legacy_fields["MESSAGES[0][user][name]"] == "+79990000000"
@@ -1,58 +1,78 @@
#!/usr/bin/env bash
# Создаёт 9 персональных уведомлений всех видов контура P для одного user_id.
# Запускать на ВМ из каталога backend: /opt/han-chat/backend
# Запускать на ВМ1 под root/admin (sudo -i). Секреты не читаются из /etc/han/vm1.env.
#
# cd /opt/han-chat/backend
# sed -i 's/\r$//' deployment/scripts/seed-personal-notifications-test.sh
# chmod +x deployment/scripts/seed-personal-notifications-test.sh
# ./deployment/scripts/seed-personal-notifications-test.sh
# sed -i 's/\r$//' /opt/han-chat/current/backend/deployment/scripts/seed-personal-notifications-test.sh
# chmod +x /opt/han-chat/current/backend/deployment/scripts/seed-personal-notifications-test.sh
# /opt/han-chat/current/backend/deployment/scripts/seed-personal-notifications-test.sh
#
# Токен берётся из .env (NOTIFICATIONS_TOKEN_PRODUCER_TEST) — тот же, что у api-backend.
# При старте api-backend синхронизирует hash токена в notification_sources.
# Токен: runtime-файл NOTIFICATIONS_TOKEN_PRODUCER_TEST
# (/run/han-chat/secrets/…, тот же, что у api-backend). Hash в notification_sources
# синхронизируется при старте api-backend.
set -euo pipefail
cd "$(dirname "$0")/../.."
SCRIPT_DIR=$(CDPATH= cd -- "$(dirname "$0")" && pwd)
if [[ -f "${SCRIPT_DIR}/../../docker-compose.yml" ]]; then
cd "${SCRIPT_DIR}/../.."
else
cd "${HAN_DEPLOY_DIR:-/opt/han-chat/current/backend}"
fi
ENV_FILE="${ENV_FILE:-.env}"
CONFIG_FILE="${CONFIG_FILE:-/etc/han/vm1.env}"
SECRETS_LAUNCHER="${SECRETS_LAUNCHER:-/usr/local/lib/han-secrets/han-secrets}"
if [[ ! -x "$SECRETS_LAUNCHER" ]]; then
SECRETS_LAUNCHER="deployment/secrets/han-secrets"
fi
if [[ ! -f "$ENV_FILE" ]]; then
echo "Не найден $ENV_FILE. Запускайте из каталога backend." >&2
if [[ ! -f "$CONFIG_FILE" ]]; then
echo "Не найден конфиг ${CONFIG_FILE} (ожидается /etc/han/vm1.env)." >&2
exit 1
fi
env_value() {
python3 - "$ENV_FILE" "$1" <<'PY'
import sys
from pathlib import Path
if [[ "${HAN_SECRETS_ACTIVE:-0}" != "1" ]]; then
[[ -x "$SECRETS_LAUNCHER" ]] || {
echo "Secret launcher is required: $SECRETS_LAUNCHER" >&2
exit 66
}
exec "$SECRETS_LAUNCHER" run --config "$CONFIG_FILE" -- "$0" "$@"
fi
path, wanted = sys.argv[1:]
for raw in Path(path).read_text(encoding="utf-8").splitlines():
line = raw.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, value = line.split("=", 1)
if key.strip() == wanted:
value = value.strip()
if len(value) >= 2 and value[0] == value[-1] and value[0] in "\"'":
value = value[1:-1]
print(value)
break
else:
raise SystemExit(f"missing environment variable: {wanted}")
PY
compose() {
if [[ -x /usr/local/sbin/han-vm1-compose ]]; then
/usr/local/sbin/han-vm1-compose "$@"
else
docker compose --env-file "$CONFIG_FILE" "$@"
fi
}
trim_token() {
printf '%s' "$1" | tr -d '\r\n\t '
}
read_secret_file() {
local path="${1:-}"
[[ -n "$path" && -r "$path" ]] || return 1
python3 - "$path" <<'PY'
from pathlib import Path
import sys
print(Path(sys.argv[1]).read_text(encoding="utf-8").strip())
PY
}
TOKEN="$(trim_token "${NOTIFICATIONS_TOKEN_PRODUCER_TEST:-}")"
if [[ -z "$TOKEN" ]]; then
TOKEN="$(trim_token "$(env_value NOTIFICATIONS_TOKEN_PRODUCER_TEST 2>/dev/null || true)")"
TOKEN="$(trim_token "$(read_secret_file "${NOTIFICATIONS_TOKEN_PRODUCER_TEST_FILE:-}" 2>/dev/null || true)")"
fi
if [[ -z "$TOKEN" ]]; then
read -rsp "NOTIFICATIONS_TOKEN_PRODUCER_TEST (из .env не найден): " TOKEN
TOKEN="$(trim_token "$(read_secret_file "${HAN_RUNTIME_SECRET_DIR:-/run/han-chat/secrets}/NOTIFICATIONS_TOKEN_PRODUCER_TEST" 2>/dev/null || true)")"
fi
if [[ -z "$TOKEN" ]]; then
TOKEN="$(trim_token "$(compose exec -T api-backend python3 -c 'import os; print(os.getenv("NOTIFICATIONS_TOKEN_PRODUCER_TEST") or "")' 2>/dev/null || true)")"
fi
if [[ -z "$TOKEN" ]]; then
read -rsp "NOTIFICATIONS_TOKEN_PRODUCER_TEST (runtime secret не найден): " TOKEN
echo
TOKEN="$(trim_token "$TOKEN")"
fi
@@ -65,7 +85,7 @@ if [[ -z "${TOKEN}" || -z "${USER_ID}" ]]; then
exit 1
fi
echo "Токен: ${#TOKEN} символов (первые 8: ${TOKEN:0:8}…)"
echo "Токен: ${#TOKEN} символов (значение не печатается)."
PAYLOAD_DIR="$(mktemp -d)"
trap 'rm -rf "$PAYLOAD_DIR"' EXIT
@@ -103,22 +123,48 @@ create_notification() {
fi
echo
echo "========== ${label} =========="
docker run --rm \
--network han-chat-backend \
-v "${payload_file}:/payload.json:ro" \
curlimages/curl:latest \
-sS -i \
-X POST \
'http://api-backend:8000/internal/notifications/v1/notifications' \
-H "Authorization: Bearer ${TOKEN}" \
-H 'Content-Type: application/json; charset=utf-8' \
--data-binary @/payload.json
compose exec -T \
-e "NOTIFICATIONS_PRODUCER_TOKEN=${TOKEN}" \
api-backend python3 -c "
import os
import sys
import urllib.error
import urllib.request
payload = sys.stdin.buffer.read()
request = urllib.request.Request(
'http://127.0.0.1:8000/internal/notifications/v1/notifications',
data=payload,
method='POST',
headers={
'Authorization': 'Bearer ' + os.environ['NOTIFICATIONS_PRODUCER_TOKEN'],
'Content-Type': 'application/json; charset=utf-8',
},
)
try:
with urllib.request.urlopen(request, timeout=60) as response:
sys.stdout.write('HTTP/1.1 %s\n' % response.status)
for key, value in response.headers.items():
sys.stdout.write('%s: %s\n' % (key, value))
sys.stdout.write('\n')
sys.stdout.write(response.read().decode('utf-8', errors='replace'))
sys.stdout.write('\n')
except urllib.error.HTTPError as exc:
sys.stdout.write('HTTP/1.1 %s\n' % exc.code)
for key, value in exc.headers.items():
sys.stdout.write('%s: %s\n' % (key, value))
sys.stdout.write('\n')
sys.stdout.write(exc.read().decode('utf-8', errors='replace'))
sys.stdout.write('\n')
if exc.code >= 500 or exc.code in (401, 403):
raise SystemExit(1)
" <"$payload_file"
}
prepare_docs_in_s3() {
echo >&2
echo "========== PREP: загрузка тестовых документов в S3 для docs_ready ==========" >&2
docker compose --env-file "$ENV_FILE" exec -T \
compose exec -T \
-e "USER_ID=${USER_ID}" \
-e "RUN_ID=${RUN_ID}" \
api-backend python3 - <<'PY'
@@ -443,6 +489,7 @@ echo
echo "========== Готово =========="
echo "External ID prefix: ${RUN_ID}-*"
echo
echo "Если видите 401: проверьте NOTIFICATIONS_TOKEN_PRODUCER_TEST в .env и перезапустите api-backend."
echo " grep NOTIFICATIONS_TOKEN_PRODUCER_TEST .env"
echo " docker compose --env-file .env up -d --force-recreate api-backend"
echo "Если видите 401: проверьте runtime-секрет NOTIFICATIONS_TOKEN_PRODUCER_TEST"
echo "и hash в han_app.notification_sources, затем пересоздайте api-backend:"
echo " test -r /run/han-chat/secrets/NOTIFICATIONS_TOKEN_PRODUCER_TEST"
echo " /usr/local/sbin/han-vm1-compose up -d --force-recreate api-backend"
@@ -63,7 +63,7 @@ export default function ProfileScreen() {
<Feather name="user" size={40} color={colors.primaryForeground} />
</View>
<Text style={stylesLocal.name}>{fullName}</Text>
<Text style={styles.muted}>{personal?.citizenship ? `Гражданство: ${personal.citizenship}` : "Мигрант"}</Text>
<Text style={styles.muted}>{personal?.citizenship ? `Гражданство: ${personal.citizenship}` : "Гражданство не указано"}</Text>
</View>
{(profile.isLoading || documents.isLoading) && <View style={{ padding: spacing.lg }}><Loading /></View>}
@@ -111,7 +111,8 @@ Caller `api-backend` передаёт `BITRIX_LOCAL_APP_INTERNAL_TOKEN`, зна
"occurred_at": "2026-07-10T09:00:00Z",
"user": {
"id": "uuid",
"display_name": "Клиент HAN"
"display_name": "Новый клиент HAN",
"phone": "+79990000000"
},
"message": {
"content_kind": "text",
@@ -128,7 +129,7 @@ Caller `api-backend` передаёт `BITRIX_LOCAL_APP_INTERNAL_TOKEN`, зна
"message_id": "uuid",
"external_chat_id": "uuid",
"occurred_at": "2026-07-10T09:00:00Z",
"user": {"id": "uuid", "display_name": "Клиент HAN"},
"user": {"id": "uuid", "display_name": "Новый клиент HAN", "phone": "+79990000000"},
"message": {
"content_kind": "file",
"text": "",
@@ -148,7 +149,8 @@ Caller `api-backend` передаёт `BITRIX_LOCAL_APP_INTERNAL_TOKEN`, зна
- `external_chat_id` строго UUID и равен App `dialog_id`;
- `text` xor один file; unknown fields запрещены;
- signed URL не сохраняется в обычные логи и редактируется в durable payload по истечении необходимости;
- PII профиля не требуется; телефон/email не передаются;
- `display_name``ClientProfile.full_name`, если пусто — `Новый клиент HAN`;
- телефон клиента передаётся в `user.phone` и мапится в `MESSAGES[0][user][phone]` для CRM-лида; email не передаётся;
- fingerprint строится по стабильным полям без signed query;
- тот же key/fingerprint возвращает прежний результат;
- тот же key с иным fingerprint → `409 idempotency_key_reused`.
@@ -198,7 +200,7 @@ Caller `api-backend` передаёт `BITRIX_LOCAL_APP_INTERNAL_TOKEN`, зна
1. Аутентифицировать caller и зарезервировать `outbound_messages` по `message_id`.
2. При completed вернуть сохранённый sanitized result.
3. Собрать `MESSAGES` Bitrix: `user.id`, `message.id/date/text/files`, `chat.id`.
3. Собрать `MESSAGES` Bitrix: `user.id/name/phone`, `message.id/date/text/files`, `chat.id`.
4. Вызвать `imconnector.send.messages` с `CONNECTOR=han_mobile_app`, `LINE=8`.
5. Извлечь `CHAT_ID`, session `ID`, Bitrix message id из допускаемых вариантов ответа.
6. В одной транзакции upsert `dialog_sessions`, записать result, status `delivered`.