Разработана первая версия приложений
This commit is contained in:
@@ -0,0 +1,12 @@
|
||||
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 .
|
||||
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)"
|
||||
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 sqlalchemy.ext.asyncio import async_engine_from_config
|
||||
|
||||
from app.models import Base
|
||||
|
||||
config = context.config
|
||||
url = os.environ["BITRIX_DATABASE_URL"].replace("postgresql://", "postgresql+asyncpg://", 1)
|
||||
config.set_main_option("sqlalchemy.url", url)
|
||||
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 = async_engine_from_config(config.get_section(config.config_ini_section) or {})
|
||||
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())
|
||||
@@ -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,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,70 @@
|
||||
import base64
|
||||
import os
|
||||
import uuid
|
||||
|
||||
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 pytest
|
||||
|
||||
from app.main import (
|
||||
TokenCipher,
|
||||
canonical_fingerprint,
|
||||
normalize_event,
|
||||
retry_delay,
|
||||
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": [
|
||||
{
|
||||
"chat": {"id": external},
|
||||
"message": {"id": "b-1", "text": "Ответ", "files": []},
|
||||
}
|
||||
]
|
||||
},
|
||||
}
|
||||
)
|
||||
assert message["event_type"] == "message.new"
|
||||
assert message["external_chat_id"] == external
|
||||
closed = normalize_event(
|
||||
{"event": "ONIMCONNECTORDIALOGFINISH", "data": {"external_chat_id": external}}
|
||||
)
|
||||
assert closed["event_type"] == "dialog.closed"
|
||||
|
||||
|
||||
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
|
||||
Reference in New Issue
Block a user