Настроен файлообмен
This commit is contained in:
@@ -6,6 +6,7 @@ import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import logging
|
||||
import mimetypes
|
||||
import re
|
||||
import secrets
|
||||
import uuid
|
||||
@@ -259,18 +260,48 @@ def normalize_event(payload: dict[str, Any]) -> dict[str, Any] | None:
|
||||
files_raw = first(data, ("MESSAGES", "0", "message", "files"), ("MESSAGE", "FILES")) or []
|
||||
if isinstance(files_raw, dict):
|
||||
files_raw = list(files_raw.values())
|
||||
files = [
|
||||
{
|
||||
"name": str(item.get("name") or item.get("NAME") or "attachment"),
|
||||
"mime_type": str(
|
||||
item.get("type") or item.get("TYPE") or "application/octet-stream"
|
||||
),
|
||||
files = []
|
||||
for item in files_raw:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
name = str(item.get("name") or item.get("NAME") or "attachment")
|
||||
declared_type = str(
|
||||
item.get("mimeType") or item.get("mime_type") or item.get("mime") or ""
|
||||
)
|
||||
if not declared_type:
|
||||
candidate = str(item.get("type") or item.get("TYPE") or "")
|
||||
declared_type = candidate if "/" in candidate else ""
|
||||
guessed_type, _ = mimetypes.guess_type(name)
|
||||
normalized_file = {
|
||||
"name": name,
|
||||
"mime_type": declared_type or guessed_type or "application/octet-stream",
|
||||
"size_bytes": int(item.get("size") or item.get("SIZE") or 0),
|
||||
"download_url": str(item.get("url") or item.get("URL") or ""),
|
||||
"download_url": str(
|
||||
item.get("url")
|
||||
or item.get("URL")
|
||||
or item.get("urlDownload")
|
||||
or item.get("downloadUrl")
|
||||
or item.get("downloadLink")
|
||||
or item.get("link")
|
||||
or item.get("URL_DOWNLOAD")
|
||||
or ""
|
||||
),
|
||||
}
|
||||
for item in files_raw
|
||||
if isinstance(item, dict)
|
||||
]
|
||||
file_id = (
|
||||
item.get("id")
|
||||
or item.get("ID")
|
||||
or item.get("fileId")
|
||||
or item.get("file_id")
|
||||
or item.get("FILE_ID")
|
||||
)
|
||||
if file_id:
|
||||
normalized_file["_bitrix_file_id"] = str(file_id)
|
||||
if not normalized_file["download_url"] and not file_id:
|
||||
logger.warning(
|
||||
"inbound file has no supported download reference; keys=%s",
|
||||
sorted(str(key) for key in item),
|
||||
)
|
||||
files.append(normalized_file)
|
||||
if not text_value and not files:
|
||||
raise ValueError("empty message")
|
||||
message = {"text": text_value, "files": files}
|
||||
@@ -302,8 +333,14 @@ class BitrixClient:
|
||||
self.limit = asyncio.Semaphore(settings.bitrix_http_max_concurrency)
|
||||
self.refresh_lock = asyncio.Lock()
|
||||
|
||||
async def ensure_fresh(self, portal: PortalInstallation) -> None:
|
||||
if portal.expires_at and portal.expires_at > now() + timedelta(seconds=60):
|
||||
async def ensure_fresh(
|
||||
self,
|
||||
portal: PortalInstallation,
|
||||
*,
|
||||
force: bool = False,
|
||||
stale_access_ciphertext: str | None = None,
|
||||
) -> None:
|
||||
if not force and portal.expires_at and portal.expires_at > now() + timedelta(seconds=60):
|
||||
return
|
||||
async with self.refresh_lock:
|
||||
async with self.sessions() as session:
|
||||
@@ -314,7 +351,17 @@ class BitrixClient:
|
||||
await session.commit()
|
||||
try:
|
||||
current = await session.get(PortalInstallation, portal.id)
|
||||
if current.expires_at and current.expires_at > now() + timedelta(seconds=60):
|
||||
refreshed_by_peer = (
|
||||
force
|
||||
and stale_access_ciphertext is not None
|
||||
and current.access_ciphertext != stale_access_ciphertext
|
||||
)
|
||||
still_fresh = (
|
||||
not force
|
||||
and current.expires_at
|
||||
and current.expires_at > now() + timedelta(seconds=60)
|
||||
)
|
||||
if refreshed_by_peer or still_fresh:
|
||||
portal.access_ciphertext = current.access_ciphertext
|
||||
portal.access_nonce = current.access_nonce
|
||||
portal.expires_at = current.expires_at
|
||||
@@ -370,18 +417,30 @@ class BitrixClient:
|
||||
|
||||
async def call(self, portal: PortalInstallation, method: str, fields: dict[str, Any]) -> dict:
|
||||
await self.ensure_fresh(portal)
|
||||
token = self.cipher.decrypt(
|
||||
portal.access_ciphertext,
|
||||
portal.access_nonce,
|
||||
portal.member_id,
|
||||
portal.domain,
|
||||
"access",
|
||||
)
|
||||
url = f"https://{portal.domain}/rest/{method}.json"
|
||||
async with self.limit:
|
||||
response = await self.http.post(
|
||||
url, data={**fields, "auth": token}, follow_redirects=False
|
||||
|
||||
async def post() -> httpx.Response:
|
||||
token = self.cipher.decrypt(
|
||||
portal.access_ciphertext,
|
||||
portal.access_nonce,
|
||||
portal.member_id,
|
||||
portal.domain,
|
||||
"access",
|
||||
)
|
||||
async with self.limit:
|
||||
return await self.http.post(
|
||||
url, data={**fields, "auth": token}, follow_redirects=False
|
||||
)
|
||||
|
||||
stale_access_ciphertext = portal.access_ciphertext
|
||||
response = await post()
|
||||
if response.status_code == 401:
|
||||
await self.ensure_fresh(
|
||||
portal,
|
||||
force=True,
|
||||
stale_access_ciphertext=stale_access_ciphertext,
|
||||
)
|
||||
response = await post()
|
||||
response.raise_for_status()
|
||||
body = response.json()
|
||||
if body.get("error"):
|
||||
@@ -693,6 +752,14 @@ def create_app(settings: Settings | None = None) -> FastAPI:
|
||||
try:
|
||||
result = await deliver_outbound(request.app, row.id)
|
||||
except Exception as exc:
|
||||
logger.exception(
|
||||
"outbound delivery failed",
|
||||
extra={
|
||||
"request_id": request.state.request_id,
|
||||
"message_id": str(dto.message_id),
|
||||
"error_type": type(exc).__name__,
|
||||
},
|
||||
)
|
||||
async with request.app.state.sessions() as session:
|
||||
current = await session.get(OutboundMessage, row.id, with_for_update=True)
|
||||
if current:
|
||||
@@ -1078,9 +1145,11 @@ async def process_inbox(app: FastAPI) -> None:
|
||||
if not row:
|
||||
return
|
||||
try:
|
||||
payload = json.loads(json.dumps(row.normalized_json))
|
||||
await resolve_inbound_file_urls(app, payload)
|
||||
response = await app.state.http.post(
|
||||
app.state.settings.bitrix_api_forward_url,
|
||||
json=row.normalized_json,
|
||||
json=payload,
|
||||
headers={
|
||||
"Authorization": f"Bearer {app.state.settings.bitrix_api_forward_token}",
|
||||
"X-Request-ID": str(uuid.uuid4()),
|
||||
@@ -1105,10 +1174,40 @@ async def process_inbox(app: FastAPI) -> None:
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
except Exception:
|
||||
except Exception as exc:
|
||||
logger.exception(
|
||||
"inbound forward failed",
|
||||
extra={
|
||||
"inbox_id": str(row.id),
|
||||
"error_type": type(exc).__name__,
|
||||
},
|
||||
)
|
||||
await mark_retry(app, InboxEvent, row.id, "api_forward_failed")
|
||||
|
||||
|
||||
async def resolve_inbound_file_urls(app: FastAPI, payload: dict[str, Any]) -> None:
|
||||
message = payload.get("message") or {}
|
||||
for item in message.get("files") or []:
|
||||
file_id = item.pop("_bitrix_file_id", "")
|
||||
if item.get("download_url"):
|
||||
continue
|
||||
if not file_id:
|
||||
raise RuntimeError("bitrix_file_id_missing")
|
||||
async with app.state.sessions() as session:
|
||||
portal = await active_portal(session)
|
||||
if not portal:
|
||||
raise RuntimeError("portal_not_installed")
|
||||
result = await app.state.bitrix.call(
|
||||
portal,
|
||||
"im.v2.File.download",
|
||||
{"id": file_id},
|
||||
)
|
||||
download_url = result.get("downloadUrl") or result.get("urlDownload")
|
||||
if not download_url:
|
||||
raise RuntimeError("bitrix_file_download_url_missing")
|
||||
item["download_url"] = str(download_url)
|
||||
|
||||
|
||||
async def process_ack(app: FastAPI) -> None:
|
||||
async with app.state.sessions() as session:
|
||||
row = await claim_one(session, DeliveryAckOutbox, ["pending", "retry"])
|
||||
@@ -1145,7 +1244,14 @@ async def process_outbound(app: FastAPI) -> None:
|
||||
return
|
||||
try:
|
||||
await deliver_outbound(app, row.id)
|
||||
except Exception:
|
||||
except Exception as exc:
|
||||
logger.exception(
|
||||
"outbound retry failed",
|
||||
extra={
|
||||
"outbound_id": str(row.id),
|
||||
"error_type": type(exc).__name__,
|
||||
},
|
||||
)
|
||||
await mark_retry(app, OutboundMessage, row.id, "bitrix_delivery_failed")
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user