Проект разделен на два репозитория
This commit is contained in:
@@ -0,0 +1,14 @@
|
||||
FROM python:3.12-slim
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1
|
||||
RUN addgroup --system app && adduser --system --ingroup app app
|
||||
WORKDIR /service
|
||||
COPY app ./app
|
||||
COPY alembic ./alembic
|
||||
COPY alembic.ini pyproject.toml ./
|
||||
RUN pip install --no-cache-dir .
|
||||
COPY --chmod=0555 container-entrypoint.sh /usr/local/bin/han-container-entrypoint
|
||||
USER app
|
||||
EXPOSE 8080
|
||||
HEALTHCHECK --interval=30s --timeout=3s CMD python -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8080/health/live', timeout=2)"
|
||||
ENTRYPOINT ["/usr/local/bin/han-container-entrypoint"]
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8080"]
|
||||
@@ -0,0 +1,30 @@
|
||||
[alembic]
|
||||
script_location = alembic
|
||||
prepend_sys_path = .
|
||||
sqlalchemy.url = postgresql+asyncpg://unused
|
||||
|
||||
[loggers]
|
||||
keys = root,sqlalchemy,alembic
|
||||
[handlers]
|
||||
keys = console
|
||||
[formatters]
|
||||
keys = generic
|
||||
[logger_root]
|
||||
level = WARN
|
||||
handlers = console
|
||||
qualname =
|
||||
[logger_sqlalchemy]
|
||||
level = WARN
|
||||
handlers =
|
||||
qualname = sqlalchemy.engine
|
||||
[logger_alembic]
|
||||
level = INFO
|
||||
handlers =
|
||||
qualname = alembic
|
||||
[handler_console]
|
||||
class = StreamHandler
|
||||
args = (sys.stderr,)
|
||||
level = NOTSET
|
||||
formatter = generic
|
||||
[formatter_generic]
|
||||
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||
@@ -0,0 +1,42 @@
|
||||
import asyncio
|
||||
import os
|
||||
|
||||
from alembic import context
|
||||
|
||||
from app.models import Base
|
||||
from app.postgres import create_postgres_engine
|
||||
|
||||
config = context.config
|
||||
url = os.environ["BITRIX_DATABASE_URL"]
|
||||
config.set_main_option("sqlalchemy.url", url.replace("%", "%%"))
|
||||
target_metadata = Base.metadata
|
||||
|
||||
|
||||
def run_offline() -> None:
|
||||
context.configure(
|
||||
url=config.get_main_option("sqlalchemy.url"),
|
||||
target_metadata=target_metadata,
|
||||
literal_binds=True,
|
||||
include_schemas=True,
|
||||
)
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
def do_run(connection) -> None:
|
||||
context.configure(connection=connection, target_metadata=target_metadata, include_schemas=True)
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
async def run_online() -> None:
|
||||
engine = create_postgres_engine(url)
|
||||
async with engine.connect() as connection:
|
||||
await connection.run_sync(do_run)
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
if context.is_offline_mode():
|
||||
run_offline()
|
||||
else:
|
||||
asyncio.run(run_online())
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
"""Create durable OAuth, mapping, inbox, outbox, setup and audit storage."""
|
||||
|
||||
from alembic import op
|
||||
|
||||
from app.models import Base
|
||||
|
||||
revision = "0001_bitrix_local"
|
||||
down_revision = None
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
op.execute("CREATE SCHEMA IF NOT EXISTS bitrix_local")
|
||||
Base.metadata.create_all(bind=bind, checkfirst=False)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
Base.metadata.drop_all(bind=op.get_bind(), checkfirst=True)
|
||||
@@ -0,0 +1 @@
|
||||
"""HAN Bitrix24 Open Lines adapter."""
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,188 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from sqlalchemy import (
|
||||
BigInteger,
|
||||
DateTime,
|
||||
ForeignKey,
|
||||
Index,
|
||||
Integer,
|
||||
JSON,
|
||||
String,
|
||||
Text,
|
||||
UniqueConstraint,
|
||||
text,
|
||||
)
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
|
||||
|
||||
SCHEMA = "bitrix_local"
|
||||
|
||||
|
||||
def now() -> datetime:
|
||||
return datetime.now(UTC)
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
pass
|
||||
|
||||
|
||||
class Common:
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now)
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now, onupdate=now)
|
||||
record_status: Mapped[str] = mapped_column(String(1), default="A")
|
||||
status_changed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
status_change_reason: Mapped[str | None] = mapped_column(String(255))
|
||||
|
||||
|
||||
class PortalInstallation(Common, Base):
|
||||
__tablename__ = "portal_installations"
|
||||
__table_args__ = (
|
||||
Index(
|
||||
"uq_portal_active_domain",
|
||||
"domain",
|
||||
unique=True,
|
||||
postgresql_where=text("record_status = 'A'"),
|
||||
),
|
||||
{"schema": SCHEMA},
|
||||
)
|
||||
member_id: Mapped[str] = mapped_column(String(128), unique=True)
|
||||
domain: Mapped[str] = mapped_column(String(255))
|
||||
client_endpoint: Mapped[str] = mapped_column(String(1024))
|
||||
access_ciphertext: Mapped[str] = mapped_column(Text)
|
||||
access_nonce: Mapped[str] = mapped_column(String(64))
|
||||
refresh_ciphertext: Mapped[str] = mapped_column(Text)
|
||||
refresh_nonce: Mapped[str] = mapped_column(String(64))
|
||||
application_ciphertext: Mapped[str] = mapped_column(Text)
|
||||
application_nonce: Mapped[str] = mapped_column(String(64))
|
||||
key_version: Mapped[str] = mapped_column(String(32))
|
||||
expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
scope: Mapped[str | None] = mapped_column(Text)
|
||||
install_status: Mapped[str] = mapped_column(String(32), default="installed")
|
||||
setup_status: Mapped[str] = mapped_column(String(32), default="pending")
|
||||
last_refresh_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
last_error_code: Mapped[str | None] = mapped_column(String(64))
|
||||
|
||||
|
||||
class ConnectorSetup(Common, Base):
|
||||
__tablename__ = "connector_setup"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("portal_id", "connector_id", "line_id"),
|
||||
Index("ix_setup_retry", "next_retry_at", "attempt_count"),
|
||||
{"schema": SCHEMA},
|
||||
)
|
||||
portal_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey(f"{SCHEMA}.portal_installations.id")
|
||||
)
|
||||
connector_id: Mapped[str] = mapped_column(String(64))
|
||||
line_id: Mapped[str] = mapped_column(String(32))
|
||||
registered: Mapped[bool] = mapped_column(default=False)
|
||||
activated: Mapped[bool] = mapped_column(default=False)
|
||||
bindings_json: Mapped[dict] = mapped_column(JSON, default=dict)
|
||||
desired_version: Mapped[str] = mapped_column(String(32), default="1")
|
||||
observed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
next_retry_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
attempt_count: Mapped[int] = mapped_column(Integer, default=0)
|
||||
lease_until: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
last_error_code: Mapped[str | None] = mapped_column(String(64))
|
||||
|
||||
|
||||
class DialogSession(Common, Base):
|
||||
__tablename__ = "dialog_sessions"
|
||||
__table_args__ = (
|
||||
Index(
|
||||
"uq_dialog_active_external_chat",
|
||||
"external_chat_id",
|
||||
unique=True,
|
||||
postgresql_where=text("record_status = 'A'"),
|
||||
),
|
||||
Index("ix_dialog_bitrix_chat", "bitrix_chat_id"),
|
||||
Index("ix_dialog_session", "session_id"),
|
||||
Index("ix_dialog_status_updated", "status", "updated_at"),
|
||||
{"schema": SCHEMA},
|
||||
)
|
||||
external_chat_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True))
|
||||
bitrix_chat_id: Mapped[int | None] = mapped_column(BigInteger)
|
||||
session_id: Mapped[str | None] = mapped_column(String(255))
|
||||
portal_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey(f"{SCHEMA}.portal_installations.id")
|
||||
)
|
||||
status: Mapped[str] = mapped_column(String(16), default="open")
|
||||
|
||||
|
||||
class InboxEvent(Common, Base):
|
||||
__tablename__ = "inbox_events"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("event_id"),
|
||||
Index(
|
||||
"uq_inbox_chat_message",
|
||||
"external_chat_id",
|
||||
"bitrix_message_id",
|
||||
unique=True,
|
||||
postgresql_where=text("bitrix_message_id IS NOT NULL"),
|
||||
),
|
||||
Index("ix_inbox_worker", "status", "next_attempt_at"),
|
||||
{"schema": SCHEMA},
|
||||
)
|
||||
event_id: Mapped[str] = mapped_column(String(255))
|
||||
event_type: Mapped[str] = mapped_column(String(64))
|
||||
external_chat_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True))
|
||||
bitrix_message_id: Mapped[str | None] = mapped_column(String(255))
|
||||
payload_fingerprint: Mapped[str] = mapped_column(String(64))
|
||||
normalized_json: Mapped[dict] = mapped_column(JSON)
|
||||
status: Mapped[str] = mapped_column(String(32), default="received")
|
||||
attempt_count: Mapped[int] = mapped_column(Integer, default=0)
|
||||
next_attempt_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now)
|
||||
lease_until: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
api_ack_status: Mapped[str | None] = mapped_column(String(32))
|
||||
delivery_ack_status: Mapped[str | None] = mapped_column(String(32))
|
||||
last_error_code: Mapped[str | None] = mapped_column(String(64))
|
||||
|
||||
|
||||
class OutboundMessage(Common, Base):
|
||||
__tablename__ = "outbound_messages"
|
||||
__table_args__ = (Index("ix_outbound_worker", "status", "next_attempt_at"), {"schema": SCHEMA})
|
||||
message_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), unique=True)
|
||||
external_chat_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True))
|
||||
request_fingerprint: Mapped[str] = mapped_column(String(64))
|
||||
payload_json: Mapped[dict] = mapped_column(JSON)
|
||||
status: Mapped[str] = mapped_column(String(32), default="received")
|
||||
bitrix_message_id: Mapped[str | None] = mapped_column(String(255))
|
||||
response_json: Mapped[dict | None] = mapped_column(JSON)
|
||||
attempt_count: Mapped[int] = mapped_column(Integer, default=0)
|
||||
next_attempt_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now)
|
||||
lease_until: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
last_error_code: Mapped[str | None] = mapped_column(String(64))
|
||||
|
||||
|
||||
class DeliveryAckOutbox(Common, Base):
|
||||
__tablename__ = "delivery_ack_outbox"
|
||||
__table_args__ = (Index("ix_ack_worker", "status", "next_attempt_at"), {"schema": SCHEMA})
|
||||
inbox_event_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey(f"{SCHEMA}.inbox_events.id"), unique=True
|
||||
)
|
||||
payload_json: Mapped[dict] = mapped_column(JSON)
|
||||
status: Mapped[str] = mapped_column(String(32), default="pending")
|
||||
attempt_count: Mapped[int] = mapped_column(Integer, default=0)
|
||||
next_attempt_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now)
|
||||
lease_until: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
last_error_code: Mapped[str | None] = mapped_column(String(64))
|
||||
|
||||
|
||||
class InstallRun(Common, Base):
|
||||
__tablename__ = "install_runs"
|
||||
__table_args__ = ({"schema": SCHEMA},)
|
||||
portal_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True))
|
||||
status: Mapped[str] = mapped_column(String(32))
|
||||
result_json: Mapped[dict] = mapped_column(JSON, default=dict)
|
||||
|
||||
|
||||
class AuditEvent(Common, Base):
|
||||
__tablename__ = "audit_events"
|
||||
__table_args__ = (Index("ix_audit_created", "created_at"), {"schema": SCHEMA})
|
||||
event_type: Mapped[str] = mapped_column(String(128))
|
||||
actor_type: Mapped[str] = mapped_column(String(32), default="system")
|
||||
safe_details: Mapped[dict] = mapped_column(JSON, default=dict)
|
||||
@@ -0,0 +1,25 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import asyncpg
|
||||
from sqlalchemy.ext.asyncio import AsyncEngine, create_async_engine
|
||||
|
||||
|
||||
def asyncpg_dsn(url: str) -> str:
|
||||
if url.startswith("postgresql+asyncpg://"):
|
||||
return url.replace("postgresql+asyncpg://", "postgresql://", 1)
|
||||
return url
|
||||
|
||||
|
||||
def create_postgres_engine(url: str, **engine_options: Any) -> AsyncEngine:
|
||||
dsn = asyncpg_dsn(url)
|
||||
|
||||
async def connect():
|
||||
return await asyncpg.connect(dsn=dsn)
|
||||
|
||||
return create_async_engine(
|
||||
"postgresql+asyncpg://",
|
||||
async_creator=connect,
|
||||
**engine_options,
|
||||
)
|
||||
@@ -0,0 +1,23 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
for name in ${HAN_SECRET_VARS:-}; do
|
||||
case "$name" in
|
||||
""|[0-9]*|*[!A-Z0-9_]*)
|
||||
echo "container secrets: invalid variable name" >&2
|
||||
exit 64
|
||||
;;
|
||||
*) ;;
|
||||
esac
|
||||
eval "file=\${${name}_FILE:-}"
|
||||
if [ -z "$file" ] || [ ! -r "$file" ]; then
|
||||
echo "container secrets: missing file for $name" >&2
|
||||
exit 66
|
||||
fi
|
||||
value=$(cat "$file")
|
||||
export "$name=$value"
|
||||
unset "${name}_FILE"
|
||||
done
|
||||
|
||||
unset HAN_SECRET_VARS
|
||||
exec "$@"
|
||||
@@ -0,0 +1,104 @@
|
||||
openapi: 3.1.0
|
||||
info: {title: HAN Bitrix24 Local App, version: 1.0.0}
|
||||
paths:
|
||||
/bitrix/handler:
|
||||
get: {responses: {"200": {description: Callback probe}}}
|
||||
post:
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json: {schema: {type: object}}
|
||||
application/x-www-form-urlencoded: {schema: {type: object}}
|
||||
multipart/form-data: {schema: {type: object}}
|
||||
responses:
|
||||
"200": {description: Duplicate or ignored callback}
|
||||
"202": {description: Durably accepted callback}
|
||||
"400": {description: Invalid callback}
|
||||
"403": {description: Invalid application token}
|
||||
/bitrix/install:
|
||||
get: {responses: {"200": {description: Install probe}}}
|
||||
post:
|
||||
responses:
|
||||
"200": {description: OAuth saved and setup attempted}
|
||||
"400": {description: Invalid install callback}
|
||||
/bitrix/placement:
|
||||
get: {responses: {"200": {description: Connector placement HTML}}}
|
||||
/health/live:
|
||||
get: {responses: {"200": {description: Live}}}
|
||||
/health/ready:
|
||||
get: {responses: {"200": {description: Ready}, "503": {description: Not ready}}}
|
||||
/internal/openlines/v1/messages:
|
||||
post:
|
||||
security: [{BearerAuth: []}]
|
||||
parameters:
|
||||
- {name: Idempotency-Key, in: header, required: true, schema: {type: string}}
|
||||
- {$ref: "#/components/parameters/RequestId"}
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json: {schema: {$ref: "#/components/schemas/OutboundMessage"}}
|
||||
responses:
|
||||
"201": {description: Delivered}
|
||||
"200": {description: Idempotent duplicate}
|
||||
"400": {description: Invalid request}
|
||||
"401": {description: Unauthorized}
|
||||
"409": {description: Idempotency key reused}
|
||||
"503": {description: Dependency unavailable or ambiguous delivery}
|
||||
/internal/openlines/v1/dialogs/{external_chat_id}:
|
||||
get:
|
||||
security: [{BearerAuth: []}]
|
||||
parameters:
|
||||
- {name: external_chat_id, in: path, required: true, schema: {type: string, format: uuid}}
|
||||
responses:
|
||||
"200": {description: Active dialog mapping}
|
||||
"404": {description: Mapping not found}
|
||||
/internal/openlines/v1/status:
|
||||
get:
|
||||
security: [{BearerAuth: []}]
|
||||
responses: {"200": {description: Safe adapter status}}
|
||||
/internal/openlines/v1/setup/retry:
|
||||
post:
|
||||
security: [{BearerAuth: []}]
|
||||
responses: {"200": {description: Idempotent setup reconcile result}}
|
||||
components:
|
||||
securitySchemes:
|
||||
BearerAuth: {type: http, scheme: bearer}
|
||||
parameters:
|
||||
RequestId: {name: X-Request-ID, in: header, required: false, schema: {type: string}}
|
||||
schemas:
|
||||
OutboundFile:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required: [attachment_id, name, mime_type, size_bytes, download_url]
|
||||
properties:
|
||||
attachment_id: {type: string, format: uuid}
|
||||
name: {type: string, maxLength: 255}
|
||||
mime_type: {type: string, maxLength: 255}
|
||||
size_bytes: {type: integer, minimum: 1, maximum: 5242880}
|
||||
download_url: {type: string, maxLength: 4096}
|
||||
OutboundMessage:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required: [message_id, external_chat_id, occurred_at, user, message]
|
||||
properties:
|
||||
message_id: {type: string, format: uuid}
|
||||
external_chat_id: {type: string, format: uuid}
|
||||
occurred_at: {type: string, format: date-time}
|
||||
user:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required: [id, display_name]
|
||||
properties:
|
||||
id: {type: string, format: uuid}
|
||||
display_name: {type: string, maxLength: 255}
|
||||
message:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required: [content_kind, text, files]
|
||||
properties:
|
||||
content_kind: {type: string, enum: [text, file]}
|
||||
text: {type: string, maxLength: 10000}
|
||||
files:
|
||||
type: array
|
||||
maxItems: 1
|
||||
items: {$ref: "#/components/schemas/OutboundFile"}
|
||||
@@ -0,0 +1,33 @@
|
||||
[project]
|
||||
name = "han-bitrix-local-app"
|
||||
version = "0.1.0"
|
||||
requires-python = ">=3.12"
|
||||
dependencies = [
|
||||
"alembic>=1.16,<2",
|
||||
"asyncpg>=0.30,<1",
|
||||
"cryptography>=45,<46",
|
||||
"fastapi>=0.116,<1",
|
||||
"httpx>=0.28,<1",
|
||||
"pydantic-settings>=2.10,<3",
|
||||
"python-multipart>=0.0.20,<1",
|
||||
"sqlalchemy[asyncio]>=2.0.41,<3",
|
||||
"uvicorn[standard]>=0.35,<1",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = ["pytest>=8.4,<9", "pytest-asyncio>=1,<2", "ruff>=0.12,<1"]
|
||||
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["app"]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
asyncio_mode = "auto"
|
||||
testpaths = ["tests"]
|
||||
|
||||
[tool.ruff]
|
||||
target-version = "py312"
|
||||
line-length = 100
|
||||
@@ -0,0 +1,280 @@
|
||||
import base64
|
||||
import os
|
||||
import uuid
|
||||
from types import SimpleNamespace
|
||||
from urllib.parse import parse_qs
|
||||
|
||||
os.environ.setdefault("BITRIX_DATABASE_URL", "postgresql://unused/unused")
|
||||
os.environ.setdefault("BITRIX_CLIENT_ID", "client")
|
||||
os.environ.setdefault("BITRIX_CLIENT_SECRET", "secret")
|
||||
os.environ.setdefault("BITRIX_APPLICATION_TOKEN", "application-token")
|
||||
os.environ.setdefault("BITRIX_INTERNAL_API_TOKEN", "internal-token-32-characters-long")
|
||||
os.environ.setdefault("BITRIX_API_FORWARD_URL", "http://api/internal/openlines/v1/inbox")
|
||||
os.environ.setdefault("BITRIX_API_FORWARD_TOKEN", "forward-token-32-characters-long")
|
||||
os.environ.setdefault(
|
||||
"BITRIX_TOKEN_ENCRYPTION_KEY",
|
||||
base64.urlsafe_b64encode(b"k" * 32).decode().rstrip("="),
|
||||
)
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from app.main import (
|
||||
BitrixClient,
|
||||
TokenCipher,
|
||||
canonical_fingerprint,
|
||||
normalize_event,
|
||||
resolve_inbound_file_urls,
|
||||
retry_delay,
|
||||
safely_retryable,
|
||||
validate_portal,
|
||||
)
|
||||
|
||||
|
||||
def test_token_cipher_binds_aad():
|
||||
cipher = TokenCipher(os.environ["BITRIX_TOKEN_ENCRYPTION_KEY"], "v1")
|
||||
ciphertext, nonce = cipher.encrypt("secret", "member", "han0107.bitrix24.ru", "access")
|
||||
assert cipher.decrypt(ciphertext, nonce, "member", "han0107.bitrix24.ru", "access") == "secret"
|
||||
with pytest.raises(Exception):
|
||||
cipher.decrypt(ciphertext, nonce, "other", "han0107.bitrix24.ru", "access")
|
||||
|
||||
|
||||
def test_normalize_message_and_finish():
|
||||
external = str(uuid.uuid4())
|
||||
message = normalize_event(
|
||||
{
|
||||
"event": "ONIMCONNECTORMESSAGEADD",
|
||||
"data": {
|
||||
"MESSAGES": [
|
||||
{
|
||||
"im": {"chat_id": 1807, "message_id": 86497},
|
||||
"chat": {"id": external},
|
||||
"message": {"text": "Ответ", "files": []},
|
||||
}
|
||||
]
|
||||
},
|
||||
}
|
||||
)
|
||||
assert message["event_type"] == "message.new"
|
||||
assert message["external_chat_id"] == external
|
||||
assert message["bitrix_message_id"] == "86497"
|
||||
assert message["message"]["text"] == "Ответ"
|
||||
closed = normalize_event(
|
||||
{"event": "ONIMCONNECTORDIALOGFINISH", "data": {"external_chat_id": external}}
|
||||
)
|
||||
assert closed["event_type"] == "dialog.closed"
|
||||
|
||||
|
||||
def test_normalize_message_removes_bitrix_sender_prefix():
|
||||
external = str(uuid.uuid4())
|
||||
message = normalize_event(
|
||||
{
|
||||
"event": "ONIMCONNECTORMESSAGEADD",
|
||||
"data": {
|
||||
"MESSAGES": [
|
||||
{
|
||||
"im": {"message_id": 86498},
|
||||
"chat": {"id": external},
|
||||
"message": {
|
||||
"text": "[b]Антон Пичугин:[/b] [br]опять ты?",
|
||||
"files": [],
|
||||
},
|
||||
}
|
||||
]
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
assert message["message"]["text"] == "опять ты?"
|
||||
|
||||
|
||||
def test_normalize_bitrix_file_uses_download_url_and_infers_mime_type():
|
||||
external = str(uuid.uuid4())
|
||||
message = normalize_event(
|
||||
{
|
||||
"event": "ONIMCONNECTORMESSAGEADD",
|
||||
"data": {
|
||||
"MESSAGES": [
|
||||
{
|
||||
"im": {"chat_id": 1807, "message_id": 86498},
|
||||
"chat": {"id": external},
|
||||
"message": {
|
||||
"text": "",
|
||||
"files": [
|
||||
{
|
||||
"name": "image.png",
|
||||
"type": "image",
|
||||
"size": 941380,
|
||||
"urlDownload": "https://portal.bitrix24.ru/download/file.png",
|
||||
}
|
||||
],
|
||||
},
|
||||
}
|
||||
]
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
assert message["message"]["files"] == [
|
||||
{
|
||||
"name": "image.png",
|
||||
"mime_type": "image/png",
|
||||
"size_bytes": 941380,
|
||||
"download_url": "https://portal.bitrix24.ru/download/file.png",
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def test_normalize_bitrix_file_uses_open_lines_download_link_and_mime():
|
||||
external = str(uuid.uuid4())
|
||||
message = normalize_event(
|
||||
{
|
||||
"event": "ONIMCONNECTORMESSAGEADD",
|
||||
"data": {
|
||||
"MESSAGES": [
|
||||
{
|
||||
"im": {"chat_id": 1807, "message_id": 86499},
|
||||
"chat": {"id": external},
|
||||
"message": {
|
||||
"text": "",
|
||||
"files": [
|
||||
{
|
||||
"name": "diploma.jpg",
|
||||
"type": "image",
|
||||
"mime": "image/jpeg",
|
||||
"size": 236934,
|
||||
"downloadLink": (
|
||||
"https://portal.bitrix24.ru/download/diploma.jpg"
|
||||
),
|
||||
"link": "https://portal.bitrix24.ru/view/diploma.jpg",
|
||||
"width": 1200,
|
||||
"height": 800,
|
||||
}
|
||||
],
|
||||
},
|
||||
}
|
||||
]
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
assert message["message"]["files"] == [
|
||||
{
|
||||
"name": "diploma.jpg",
|
||||
"mime_type": "image/jpeg",
|
||||
"size_bytes": 236934,
|
||||
"download_url": "https://portal.bitrix24.ru/download/diploma.jpg",
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_inbound_file_url_from_bitrix_file_id(monkeypatch):
|
||||
class SessionContext:
|
||||
async def __aenter__(self):
|
||||
return object()
|
||||
|
||||
async def __aexit__(self, *_args):
|
||||
return None
|
||||
|
||||
class Bitrix:
|
||||
async def call(self, portal, method, params):
|
||||
assert portal == "portal"
|
||||
assert method == "im.v2.File.download"
|
||||
assert params == {"id": "5155"}
|
||||
return {"downloadUrl": "https://portal.bitrix24.ru/download/file.png"}
|
||||
|
||||
async def fake_active_portal(_session):
|
||||
return "portal"
|
||||
|
||||
monkeypatch.setattr("app.main.active_portal", fake_active_portal)
|
||||
app = SimpleNamespace(
|
||||
state=SimpleNamespace(sessions=lambda: SessionContext(), bitrix=Bitrix())
|
||||
)
|
||||
payload = {
|
||||
"message": {
|
||||
"files": [
|
||||
{
|
||||
"name": "image.png",
|
||||
"mime_type": "image/png",
|
||||
"size_bytes": 941380,
|
||||
"download_url": "",
|
||||
"_bitrix_file_id": "5155",
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
await resolve_inbound_file_urls(app, payload)
|
||||
|
||||
assert payload["message"]["files"] == [
|
||||
{
|
||||
"name": "image.png",
|
||||
"mime_type": "image/png",
|
||||
"size_bytes": 941380,
|
||||
"download_url": "https://portal.bitrix24.ru/download/file.png",
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def test_fingerprint_ignores_signed_query_and_portal_validation():
|
||||
payload = {"message": {"files": [{"download_url": "https://s3/object?sig=one"}]}}
|
||||
other = {"message": {"files": [{"download_url": "https://s3/object?sig=two"}]}}
|
||||
assert canonical_fingerprint(payload) == canonical_fingerprint(other)
|
||||
validate_portal(
|
||||
"han0107.bitrix24.ru",
|
||||
"https://han0107.bitrix24.ru/rest/",
|
||||
"han0107.bitrix24.ru",
|
||||
)
|
||||
with pytest.raises(ValueError):
|
||||
validate_portal("evil.example", "https://evil.example/rest/", "han0107.bitrix24.ru")
|
||||
assert 0 <= retry_delay(4, 300) <= 8
|
||||
|
||||
|
||||
def test_network_timeouts_are_retryable():
|
||||
assert safely_retryable(TimeoutError("Delivery operation timed out"))
|
||||
assert safely_retryable(httpx.ReadTimeout("Bitrix response timed out"))
|
||||
assert safely_retryable(httpx.ConnectTimeout("Bitrix connection timed out"))
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bitrix_call_refreshes_and_retries_once_after_401():
|
||||
auth_values: list[str] = []
|
||||
|
||||
async def handler(request: httpx.Request) -> httpx.Response:
|
||||
auth = parse_qs(request.content.decode())["auth"][0]
|
||||
auth_values.append(auth)
|
||||
if auth == "old-access":
|
||||
return httpx.Response(401, request=request)
|
||||
return httpx.Response(200, json={"result": {"ok": True}}, request=request)
|
||||
|
||||
class Cipher:
|
||||
@staticmethod
|
||||
def decrypt(ciphertext, *_):
|
||||
return ciphertext
|
||||
|
||||
portal = SimpleNamespace(
|
||||
access_ciphertext="old-access",
|
||||
access_nonce="nonce",
|
||||
member_id="member",
|
||||
domain="han0107.bitrix24.ru",
|
||||
expires_at=None,
|
||||
)
|
||||
settings = SimpleNamespace(bitrix_http_max_concurrency=2)
|
||||
|
||||
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as http:
|
||||
client = BitrixClient(http, settings, Cipher(), sessions=None)
|
||||
refresh_calls: list[bool] = []
|
||||
|
||||
async def ensure_fresh(_, *, force=False, stale_access_ciphertext=None):
|
||||
refresh_calls.append(force)
|
||||
if force:
|
||||
assert stale_access_ciphertext == "old-access"
|
||||
portal.access_ciphertext = "new-access"
|
||||
|
||||
client.ensure_fresh = ensure_fresh
|
||||
result = await client.call(portal, "imconnector.send.messages", {"MESSAGE": "test"})
|
||||
|
||||
assert result == {"ok": True}
|
||||
assert auth_values == ["old-access", "new-access"]
|
||||
assert refresh_calls == [False, True]
|
||||
Reference in New Issue
Block a user