```
feat(feishu): 添加飞书入站事件inbox和混合数据库协调功能 - 实现飞书入站事件持久化inbox机制,支持状态管理、租约锁定和重试退避 - 添加混合数据库基线协调工具,确保平台PostgreSQL结构安全对齐 - 增加运行组件心跳检测和readiness就绪检查机制 - 实现app_ticket事件的安全轮换和验证处理 - 添加生产环境运行编排和fail-closed安全机制 - 支持webhook快速确认和长连接独立进程处理 - 完善个人数据擦除时的待处理事件清理功能 ```
This commit is contained in:
@@ -21,6 +21,20 @@ class FeishuEventSource(StrEnum):
|
||||
LONG_CONNECTION = "long_connection"
|
||||
|
||||
|
||||
class FeishuInboundStatus(StrEnum):
|
||||
PENDING = "pending"
|
||||
PROCESSING = "processing"
|
||||
SUCCEEDED = "succeeded"
|
||||
RETRY = "retry"
|
||||
FAILED = "failed"
|
||||
|
||||
|
||||
class FeishuEventTransport(StrEnum):
|
||||
DISABLED = "disabled"
|
||||
WEBHOOK = "webhook"
|
||||
LONG_CONNECTION = "long_connection"
|
||||
|
||||
|
||||
class FeishuPayloadKey(StrEnum):
|
||||
APP_ACCESS_TOKEN = "app_access_token"
|
||||
APP_ID = "app_id"
|
||||
@@ -180,6 +194,10 @@ FEISHU_RECEIVE_ID_MISSING = "receive_id or FEISHU_DEFAULT_CHAT_ID is required"
|
||||
FEISHU_SUCCESS_CODE = 0
|
||||
FEISHU_DEFAULT_TOKEN_EXPIRE_SECONDS = 7200
|
||||
FEISHU_TOKEN_EXPIRE_SAFETY_SECONDS = 300
|
||||
FEISHU_INBOUND_BATCH_SIZE = 100
|
||||
FEISHU_INBOUND_LEASE_SECONDS = 300
|
||||
FEISHU_INBOUND_MAX_ATTEMPTS = 4
|
||||
FEISHU_INBOUND_RETRY_DELAYS_SECONDS = (60, 300, 900)
|
||||
FEISHU_AI_REPLY_TITLE = "AI 回复"
|
||||
FEISHU_EMPTY_CARD_TEXT = "暂无数据"
|
||||
FEISHU_MENTION_PATTERN = r"@\S+"
|
||||
|
||||
@@ -1,12 +1,24 @@
|
||||
import json
|
||||
import logging
|
||||
from socket import gethostname
|
||||
from threading import Event, Thread
|
||||
from time import monotonic
|
||||
from typing import Any
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
from app.core.background.task_queue import enqueue_feishu_inbound_event
|
||||
from app.core.config import get_settings
|
||||
from app.core.constants import ActorValue
|
||||
from app.core.database import SessionLocal
|
||||
from app.modules.feishu.constants import FEISHU_DEFAULT_OPEN_API_DOMAIN, FeishuEventSource
|
||||
from app.application.feishu import FeishuEventService
|
||||
from app.modules.feishu.constants import (
|
||||
FEISHU_DEFAULT_OPEN_API_DOMAIN,
|
||||
FeishuEventSource,
|
||||
FeishuEventTransport,
|
||||
FeishuResponseKey,
|
||||
)
|
||||
from app.modules.observability.constants import HeartbeatComponent, HeartbeatStatus
|
||||
from app.modules.observability.service import ObservabilityService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -18,6 +30,118 @@ def _sdk_domain(base_url: str) -> str:
|
||||
return f"{parsed.scheme}://{parsed.netloc}"
|
||||
|
||||
|
||||
def _sdk_connection_is_open(client: Any) -> bool:
|
||||
"""Return whether the SDK exposes a currently open WebSocket connection."""
|
||||
|
||||
try:
|
||||
connection = getattr(client, "_conn", None)
|
||||
except Exception:
|
||||
return False
|
||||
if connection is None:
|
||||
return False
|
||||
|
||||
try:
|
||||
state = getattr(connection, "state", None)
|
||||
except Exception:
|
||||
return False
|
||||
if state is not None:
|
||||
state_name = getattr(state, "name", None)
|
||||
state_text = str(state_name or state).strip().lower()
|
||||
if state_text == "open" or state_text.endswith(".open"):
|
||||
return True
|
||||
if any(
|
||||
marker in state_text
|
||||
for marker in ("connecting", "closing", "closed")
|
||||
):
|
||||
return False
|
||||
|
||||
try:
|
||||
closed = getattr(connection, "closed", None)
|
||||
except Exception:
|
||||
return False
|
||||
if isinstance(closed, bool):
|
||||
return not closed
|
||||
|
||||
try:
|
||||
opened = getattr(connection, "open", None)
|
||||
except Exception:
|
||||
return False
|
||||
if isinstance(opened, bool):
|
||||
return opened
|
||||
|
||||
try:
|
||||
if getattr(connection, "close_code", None) is not None:
|
||||
return False
|
||||
transport = getattr(connection, "transport", None)
|
||||
is_closing = getattr(transport, "is_closing", None)
|
||||
if callable(is_closing) and is_closing():
|
||||
return False
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
# A non-null SDK-private connection object is not enough evidence that the
|
||||
# WebSocket handshake completed or that the transport remains usable.
|
||||
return False
|
||||
|
||||
|
||||
def _connection_heartbeat_status(client: Any) -> str:
|
||||
if _sdk_connection_is_open(client):
|
||||
return HeartbeatStatus.OK
|
||||
return HeartbeatStatus.DEGRADED
|
||||
|
||||
|
||||
def _record_runtime_heartbeat(instance_id: str, status_value: str) -> None:
|
||||
db = SessionLocal()
|
||||
try:
|
||||
ObservabilityService(db).record_heartbeat(
|
||||
component=HeartbeatComponent.FEISHU_EVENTS,
|
||||
instance_id=instance_id,
|
||||
status_value=status_value,
|
||||
actor=ActorValue.FEISHU,
|
||||
)
|
||||
except Exception:
|
||||
db.rollback()
|
||||
logger.exception("Failed to record Feishu event process heartbeat")
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def _heartbeat_loop(
|
||||
stop_event: Event,
|
||||
instance_id: str,
|
||||
interval_seconds: int,
|
||||
client: Any,
|
||||
) -> None:
|
||||
last_status: str | None = None
|
||||
next_heartbeat_at = 0.0
|
||||
while not stop_event.is_set():
|
||||
status_value = _connection_heartbeat_status(client)
|
||||
current_time = monotonic()
|
||||
if status_value != last_status or current_time >= next_heartbeat_at:
|
||||
_record_runtime_heartbeat(instance_id, status_value)
|
||||
last_status = status_value
|
||||
next_heartbeat_at = current_time + max(1, interval_seconds)
|
||||
stop_event.wait(1)
|
||||
|
||||
|
||||
def _start_heartbeat_loop(client: Any) -> tuple[Event, Thread]:
|
||||
settings = get_settings()
|
||||
stop_event = Event()
|
||||
thread = Thread(
|
||||
target=_heartbeat_loop,
|
||||
args=(
|
||||
stop_event,
|
||||
gethostname(),
|
||||
settings.heartbeat_interval_seconds,
|
||||
client,
|
||||
),
|
||||
name="feishu-events-heartbeat",
|
||||
daemon=True,
|
||||
)
|
||||
thread.start()
|
||||
return stop_event, thread
|
||||
|
||||
|
||||
def _sdk_event_to_payload(event: Any) -> dict[str, Any]:
|
||||
try:
|
||||
from lark_oapi.core.json import JSON
|
||||
@@ -42,20 +166,38 @@ def _handle_verified_sdk_event(event: Any) -> None:
|
||||
payload = _sdk_event_to_payload(event)
|
||||
db = SessionLocal()
|
||||
try:
|
||||
result = FeishuEventService(db)._handle_verified_event(
|
||||
acceptance = FeishuEventService(db).accept_verified_event(
|
||||
payload,
|
||||
source=FeishuEventSource.LONG_CONNECTION,
|
||||
auto_reply=True,
|
||||
)
|
||||
logger.info("Handled Feishu long connection event: %s", result)
|
||||
finally:
|
||||
db.close()
|
||||
if (
|
||||
acceptance.event_key is not None
|
||||
and acceptance.should_dispatch
|
||||
and get_settings().task_queue_enabled
|
||||
):
|
||||
enqueue_feishu_inbound_event(
|
||||
acceptance.event_key,
|
||||
actor=ActorValue.FEISHU,
|
||||
)
|
||||
logger.info(
|
||||
"Accepted Feishu long connection event key=%s status=%s duplicate=%s",
|
||||
acceptance.event_key,
|
||||
acceptance.response.get(FeishuResponseKey.STATUS),
|
||||
bool(acceptance.response.get(FeishuResponseKey.DUPLICATE)),
|
||||
)
|
||||
|
||||
|
||||
def run_long_connection() -> None:
|
||||
"""Start the Feishu long connection client and block forever."""
|
||||
|
||||
settings = get_settings()
|
||||
if settings.feishu_event_transport != FeishuEventTransport.LONG_CONNECTION:
|
||||
raise RuntimeError(
|
||||
"FEISHU_EVENT_TRANSPORT must be long_connection for this process"
|
||||
)
|
||||
if not settings.feishu_app_id or not settings.feishu_app_secret:
|
||||
raise RuntimeError("FEISHU_APP_ID and FEISHU_APP_SECRET are required")
|
||||
|
||||
@@ -81,7 +223,12 @@ def run_long_connection() -> None:
|
||||
domain=_sdk_domain(settings.feishu_base_url),
|
||||
)
|
||||
logger.info("Starting Feishu long connection client")
|
||||
client.start()
|
||||
stop_event, heartbeat_thread = _start_heartbeat_loop(client)
|
||||
try:
|
||||
client.start()
|
||||
finally:
|
||||
stop_event.set()
|
||||
heartbeat_thread.join(timeout=1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, Integer, String, Text
|
||||
from sqlalchemy import JSON, Boolean, DateTime, Integer, String, Text, true
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.core.database import Base
|
||||
from app.core.utils.time import utc_now
|
||||
from app.modules.feishu.constants import (
|
||||
FEISHU_INBOUND_MAX_ATTEMPTS,
|
||||
FeishuInboundStatus,
|
||||
)
|
||||
|
||||
|
||||
class FeishuEventReceipt(Base):
|
||||
@@ -15,7 +19,79 @@ class FeishuEventReceipt(Base):
|
||||
source: Mapped[str] = mapped_column(String(64), index=True)
|
||||
event_id: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True)
|
||||
message_id: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True)
|
||||
event_type: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True)
|
||||
payload: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||
auto_reply: Mapped[bool] = mapped_column(
|
||||
Boolean,
|
||||
default=True,
|
||||
server_default=true(),
|
||||
)
|
||||
status: Mapped[str] = mapped_column(
|
||||
String(32),
|
||||
default=FeishuInboundStatus.PENDING,
|
||||
server_default=FeishuInboundStatus.PENDING,
|
||||
index=True,
|
||||
)
|
||||
attempt_count: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
|
||||
max_attempts: Mapped[int] = mapped_column(
|
||||
Integer,
|
||||
default=FEISHU_INBOUND_MAX_ATTEMPTS,
|
||||
server_default=str(FEISHU_INBOUND_MAX_ATTEMPTS),
|
||||
)
|
||||
last_error: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
next_attempt_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime,
|
||||
nullable=True,
|
||||
index=True,
|
||||
)
|
||||
locked_until: Mapped[datetime | None] = mapped_column(
|
||||
DateTime,
|
||||
nullable=True,
|
||||
index=True,
|
||||
)
|
||||
locked_by: Mapped[str | None] = mapped_column(
|
||||
String(128),
|
||||
nullable=True,
|
||||
index=True,
|
||||
)
|
||||
received_at: Mapped[datetime] = mapped_column(DateTime, default=utc_now, index=True)
|
||||
processed_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime,
|
||||
nullable=True,
|
||||
index=True,
|
||||
)
|
||||
reply_payload: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||
reply_status: Mapped[str | None] = mapped_column(
|
||||
String(32),
|
||||
nullable=True,
|
||||
index=True,
|
||||
)
|
||||
reply_attempt_count: Mapped[int] = mapped_column(
|
||||
Integer,
|
||||
default=0,
|
||||
server_default="0",
|
||||
)
|
||||
reply_last_error: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
reply_next_attempt_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime,
|
||||
nullable=True,
|
||||
index=True,
|
||||
)
|
||||
reply_locked_until: Mapped[datetime | None] = mapped_column(
|
||||
DateTime,
|
||||
nullable=True,
|
||||
index=True,
|
||||
)
|
||||
reply_locked_by: Mapped[str | None] = mapped_column(
|
||||
String(128),
|
||||
nullable=True,
|
||||
index=True,
|
||||
)
|
||||
reply_sent_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime,
|
||||
nullable=True,
|
||||
index=True,
|
||||
)
|
||||
|
||||
|
||||
class FeishuAppTicket(Base):
|
||||
|
||||
@@ -1,10 +1,18 @@
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Request, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.background.task_queue import enqueue_feishu_inbound_event
|
||||
from app.core.config import get_settings
|
||||
from app.core.constants import ActorValue
|
||||
from app.core.database import get_db
|
||||
from app.core.security import ApiPrincipal, require_api_key
|
||||
from app.application.feishu import FeishuCommandService, FeishuEventService
|
||||
from app.modules.feishu.constants import FeishuEventSource, FeishuPayloadKey, FeishuResponseKey
|
||||
from app.modules.feishu.constants import (
|
||||
FeishuEventSource,
|
||||
FeishuEventTransport,
|
||||
FeishuPayloadKey,
|
||||
FeishuResponseKey,
|
||||
)
|
||||
from app.modules.feishu.event_verification import FeishuWebhookVerifier
|
||||
from app.modules.feishu.schemas import (
|
||||
FeishuCardMessage,
|
||||
@@ -19,15 +27,31 @@ router = APIRouter()
|
||||
|
||||
|
||||
@router.post("/webhook")
|
||||
async def feishu_webhook(request: Request, db: Session = Depends(get_db)) -> dict:
|
||||
async def feishu_webhook(
|
||||
request: Request,
|
||||
background_tasks: BackgroundTasks,
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
"""Handle Feishu webhook challenge and text command events."""
|
||||
|
||||
if get_settings().feishu_event_transport != FeishuEventTransport.WEBHOOK:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="Feishu webhook transport is disabled",
|
||||
)
|
||||
payload = FeishuWebhookVerifier().verify(await request.body(), request.headers)
|
||||
return FeishuEventService(db)._handle_verified_event(
|
||||
acceptance = FeishuEventService(db).accept_verified_event(
|
||||
payload,
|
||||
source=FeishuEventSource.WEBHOOK,
|
||||
auto_reply=True,
|
||||
)
|
||||
if acceptance.event_key is not None and acceptance.should_dispatch:
|
||||
background_tasks.add_task(
|
||||
enqueue_feishu_inbound_event,
|
||||
acceptance.event_key,
|
||||
actor=ActorValue.FEISHU,
|
||||
)
|
||||
return acceptance.response
|
||||
|
||||
|
||||
@router.post("/send-text", response_model=FeishuSendResult)
|
||||
|
||||
@@ -18,6 +18,7 @@ from app.modules.feishu.constants import (
|
||||
FeishuPayloadKey,
|
||||
FeishuReceiveIdType,
|
||||
)
|
||||
from app.modules.feishu.services.reply_outbox import current_reply_outbox
|
||||
|
||||
|
||||
class FeishuService:
|
||||
@@ -30,12 +31,18 @@ class FeishuService:
|
||||
self.tenant_key = _optional_text(tenant_key) or _optional_text(
|
||||
get_settings().feishu_default_tenant_key
|
||||
)
|
||||
self.message_uuid: str | None = None
|
||||
|
||||
def set_tenant_key(self, tenant_key: str | None) -> None:
|
||||
"""Set the default tenant used by subsequent outbound operations."""
|
||||
|
||||
self.tenant_key = _optional_text(tenant_key)
|
||||
|
||||
def set_message_uuid(self, message_uuid: str | None) -> None:
|
||||
"""Set the idempotency UUID used by replies in the current command."""
|
||||
|
||||
self.message_uuid = _optional_text(message_uuid)
|
||||
|
||||
def verify_event(self, payload: dict[str, Any]) -> None:
|
||||
settings = get_settings()
|
||||
expected = settings.feishu_verification_token
|
||||
@@ -62,12 +69,26 @@ class FeishuService:
|
||||
tenant_key: str | None = None,
|
||||
record_audit: bool = True,
|
||||
) -> dict[str, Any]:
|
||||
resolved_uuid = uuid or self.message_uuid
|
||||
resolved_tenant_key = self._resolve_tenant_key(tenant_key)
|
||||
reply_outbox = current_reply_outbox()
|
||||
if reply_outbox is not None:
|
||||
return reply_outbox.capture_text(
|
||||
text=text,
|
||||
receive_id=receive_id,
|
||||
default_receive_id=get_settings().feishu_default_chat_id,
|
||||
receive_id_type=receive_id_type,
|
||||
actor=actor,
|
||||
message_uuid=resolved_uuid,
|
||||
tenant_key=resolved_tenant_key,
|
||||
record_audit=record_audit,
|
||||
)
|
||||
result = self.client.send_text(
|
||||
text,
|
||||
receive_id,
|
||||
receive_id_type,
|
||||
uuid,
|
||||
tenant_key=self._resolve_tenant_key(tenant_key),
|
||||
resolved_uuid,
|
||||
tenant_key=resolved_tenant_key,
|
||||
)
|
||||
if record_audit:
|
||||
self.audit.log(
|
||||
@@ -79,7 +100,7 @@ class FeishuService:
|
||||
"receive_target_hash": _target_fingerprint(receive_id),
|
||||
FeishuPayloadKey.RECEIVE_ID_TYPE: receive_id_type,
|
||||
"content_length": len(text),
|
||||
FeishuPayloadKey.UUID: uuid,
|
||||
FeishuPayloadKey.UUID: resolved_uuid,
|
||||
},
|
||||
response_payload=result,
|
||||
)
|
||||
@@ -95,12 +116,25 @@ class FeishuService:
|
||||
uuid: str | None = None,
|
||||
tenant_key: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
resolved_uuid = uuid or self.message_uuid
|
||||
resolved_tenant_key = self._resolve_tenant_key(tenant_key)
|
||||
reply_outbox = current_reply_outbox()
|
||||
if reply_outbox is not None:
|
||||
return reply_outbox.capture_card(
|
||||
card=card,
|
||||
receive_id=receive_id,
|
||||
default_receive_id=get_settings().feishu_default_chat_id,
|
||||
receive_id_type=receive_id_type,
|
||||
actor=actor,
|
||||
message_uuid=resolved_uuid,
|
||||
tenant_key=resolved_tenant_key,
|
||||
)
|
||||
result = self.client.send_card(
|
||||
card,
|
||||
receive_id,
|
||||
receive_id_type,
|
||||
uuid,
|
||||
tenant_key=self._resolve_tenant_key(tenant_key),
|
||||
resolved_uuid,
|
||||
tenant_key=resolved_tenant_key,
|
||||
)
|
||||
self.audit.log(
|
||||
AuditLogCreate(
|
||||
@@ -111,7 +145,7 @@ class FeishuService:
|
||||
"receive_target_hash": _target_fingerprint(receive_id),
|
||||
FeishuPayloadKey.RECEIVE_ID_TYPE: receive_id_type,
|
||||
"card_element_count": len(card.get(FeishuPayloadKey.ELEMENTS) or []),
|
||||
FeishuPayloadKey.UUID: uuid,
|
||||
FeishuPayloadKey.UUID: resolved_uuid,
|
||||
},
|
||||
response_payload=result,
|
||||
)
|
||||
@@ -124,9 +158,17 @@ class FeishuService:
|
||||
actor: str = ActorValue.SYSTEM,
|
||||
tenant_key: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
resolved_tenant_key = self._resolve_tenant_key(tenant_key)
|
||||
reply_outbox = current_reply_outbox()
|
||||
if reply_outbox is not None:
|
||||
return reply_outbox.capture_image(
|
||||
image=image,
|
||||
actor=actor,
|
||||
tenant_key=resolved_tenant_key,
|
||||
)
|
||||
result = self.client.upload_image(
|
||||
image,
|
||||
tenant_key=self._resolve_tenant_key(tenant_key),
|
||||
tenant_key=resolved_tenant_key,
|
||||
)
|
||||
self.audit.log(
|
||||
AuditLogCreate(
|
||||
|
||||
17
app/modules/feishu/services/__init__.py
Normal file
17
app/modules/feishu/services/__init__.py
Normal file
@@ -0,0 +1,17 @@
|
||||
from app.modules.feishu.services.inbox import (
|
||||
FeishuInboundAcceptance,
|
||||
FeishuInboundProcessResult,
|
||||
FeishuInboundService,
|
||||
)
|
||||
from app.modules.feishu.services.context import (
|
||||
bind_inbound_event,
|
||||
current_inbound_event_key,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"FeishuInboundAcceptance",
|
||||
"FeishuInboundProcessResult",
|
||||
"FeishuInboundService",
|
||||
"bind_inbound_event",
|
||||
"current_inbound_event_key",
|
||||
]
|
||||
23
app/modules/feishu/services/context.py
Normal file
23
app/modules/feishu/services/context.py
Normal file
@@ -0,0 +1,23 @@
|
||||
from collections.abc import Iterator
|
||||
from contextlib import contextmanager
|
||||
from contextvars import ContextVar
|
||||
|
||||
_CURRENT_INBOUND_EVENT_KEY: ContextVar[str | None] = ContextVar(
|
||||
"current_feishu_inbound_event_key",
|
||||
default=None,
|
||||
)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def bind_inbound_event(event_key: str) -> Iterator[None]:
|
||||
"""Expose the current inbox key to privacy-cleanup hooks."""
|
||||
|
||||
token = _CURRENT_INBOUND_EVENT_KEY.set(event_key)
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
_CURRENT_INBOUND_EVENT_KEY.reset(token)
|
||||
|
||||
|
||||
def current_inbound_event_key() -> str | None:
|
||||
return _CURRENT_INBOUND_EVENT_KEY.get()
|
||||
1305
app/modules/feishu/services/inbox.py
Normal file
1305
app/modules/feishu/services/inbox.py
Normal file
File diff suppressed because it is too large
Load Diff
247
app/modules/feishu/services/reply_outbox.py
Normal file
247
app/modules/feishu/services/reply_outbox.py
Normal file
@@ -0,0 +1,247 @@
|
||||
import base64
|
||||
from contextlib import contextmanager
|
||||
from contextvars import ContextVar
|
||||
from copy import deepcopy
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Iterator
|
||||
|
||||
from fastapi import HTTPException, status
|
||||
|
||||
from app.modules.feishu.constants import FEISHU_RECEIVE_ID_MISSING
|
||||
|
||||
_IMAGE_PLACEHOLDER_PREFIX = "__feishu_reply_image__:"
|
||||
_reply_collector: ContextVar["FeishuReplyCollector | None"] = ContextVar(
|
||||
"feishu_reply_collector",
|
||||
default=None,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class FeishuReplyCollector:
|
||||
"""Capture outbound Feishu operations before an inbound transaction commits."""
|
||||
|
||||
operations: list[dict[str, Any]] = field(default_factory=list)
|
||||
|
||||
def capture_text(
|
||||
self,
|
||||
*,
|
||||
text: str,
|
||||
receive_id: str | None,
|
||||
default_receive_id: str | None,
|
||||
receive_id_type: str,
|
||||
actor: str,
|
||||
message_uuid: str | None,
|
||||
tenant_key: str | None,
|
||||
record_audit: bool,
|
||||
) -> dict[str, Any]:
|
||||
target = _required_receive_id(receive_id, default_receive_id)
|
||||
self.operations.append(
|
||||
{
|
||||
"kind": "text",
|
||||
"text": text,
|
||||
"receive_id": target,
|
||||
"receive_id_type": str(receive_id_type),
|
||||
"actor": str(actor),
|
||||
"message_uuid": message_uuid,
|
||||
"tenant_key": tenant_key,
|
||||
"record_audit": bool(record_audit),
|
||||
}
|
||||
)
|
||||
return {"code": 0, "queued": True}
|
||||
|
||||
def capture_card(
|
||||
self,
|
||||
*,
|
||||
card: dict[str, Any],
|
||||
receive_id: str | None,
|
||||
default_receive_id: str | None,
|
||||
receive_id_type: str,
|
||||
actor: str,
|
||||
message_uuid: str | None,
|
||||
tenant_key: str | None,
|
||||
) -> dict[str, Any]:
|
||||
target = _required_receive_id(receive_id, default_receive_id)
|
||||
self.operations.append(
|
||||
{
|
||||
"kind": "card",
|
||||
"card": deepcopy(card),
|
||||
"receive_id": target,
|
||||
"receive_id_type": str(receive_id_type),
|
||||
"actor": str(actor),
|
||||
"message_uuid": message_uuid,
|
||||
"tenant_key": tenant_key,
|
||||
"record_audit": True,
|
||||
}
|
||||
)
|
||||
return {"code": 0, "queued": True}
|
||||
|
||||
def capture_image(
|
||||
self,
|
||||
*,
|
||||
image: bytes,
|
||||
actor: str,
|
||||
tenant_key: str | None,
|
||||
) -> dict[str, Any]:
|
||||
placeholder = f"{_IMAGE_PLACEHOLDER_PREFIX}{len(self.operations)}"
|
||||
self.operations.append(
|
||||
{
|
||||
"kind": "image",
|
||||
"placeholder": placeholder,
|
||||
"image_base64": base64.b64encode(image).decode("ascii"),
|
||||
"actor": str(actor),
|
||||
"tenant_key": tenant_key,
|
||||
}
|
||||
)
|
||||
return {
|
||||
"code": 0,
|
||||
"queued": True,
|
||||
"data": {"image_key": placeholder},
|
||||
}
|
||||
|
||||
def as_payload(
|
||||
self,
|
||||
*,
|
||||
identity: tuple[str, str] | None,
|
||||
identity_fence_required: bool,
|
||||
) -> dict[str, Any] | None:
|
||||
if not self.operations:
|
||||
return None
|
||||
return {
|
||||
"version": 1,
|
||||
"identity": (
|
||||
{"tenant_key": identity[0], "open_id": identity[1]}
|
||||
if identity is not None
|
||||
else None
|
||||
),
|
||||
"identity_fence_required": identity_fence_required,
|
||||
"operations": deepcopy(self.operations),
|
||||
}
|
||||
|
||||
|
||||
@contextmanager
|
||||
def bind_reply_outbox() -> Iterator[FeishuReplyCollector]:
|
||||
"""Capture Feishu side effects for the current inbound command."""
|
||||
|
||||
collector = FeishuReplyCollector()
|
||||
token = _reply_collector.set(collector)
|
||||
try:
|
||||
yield collector
|
||||
finally:
|
||||
_reply_collector.reset(token)
|
||||
|
||||
|
||||
def current_reply_outbox() -> FeishuReplyCollector | None:
|
||||
return _reply_collector.get()
|
||||
|
||||
|
||||
def decode_image(operation: dict[str, Any]) -> bytes:
|
||||
encoded = operation.get("image_base64")
|
||||
if not isinstance(encoded, str) or not encoded:
|
||||
raise ValueError("Feishu reply image payload is unavailable")
|
||||
try:
|
||||
return base64.b64decode(encoded, validate=True)
|
||||
except (ValueError, TypeError) as exc:
|
||||
raise ValueError("Feishu reply image payload is invalid") from exc
|
||||
|
||||
|
||||
def resolved_message_operation(payload: dict[str, Any]) -> dict[str, Any]:
|
||||
operations = _operations(payload)
|
||||
messages = [
|
||||
operation
|
||||
for operation in operations
|
||||
if operation.get("kind") in {"text", "card"}
|
||||
]
|
||||
if len(messages) != 1:
|
||||
raise ValueError("Feishu reply outbox requires exactly one message")
|
||||
image_keys = {
|
||||
str(operation.get("placeholder")): str(operation.get("image_key"))
|
||||
for operation in operations
|
||||
if operation.get("kind") == "image"
|
||||
and operation.get("placeholder")
|
||||
and operation.get("image_key")
|
||||
}
|
||||
message = deepcopy(messages[0])
|
||||
if message.get("kind") == "card":
|
||||
message["card"] = _replace_image_placeholders(
|
||||
message.get("card"),
|
||||
image_keys,
|
||||
)
|
||||
return message
|
||||
|
||||
|
||||
def pending_image_indexes(payload: dict[str, Any]) -> list[int]:
|
||||
return [
|
||||
index
|
||||
for index, operation in enumerate(_operations(payload))
|
||||
if operation.get("kind") == "image" and not operation.get("image_key")
|
||||
]
|
||||
|
||||
|
||||
def operations_copy(payload: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
return deepcopy(_operations(payload))
|
||||
|
||||
|
||||
def payload_identity(payload: Any) -> tuple[str, str] | None:
|
||||
if not isinstance(payload, dict):
|
||||
return None
|
||||
identity = payload.get("identity")
|
||||
if not isinstance(identity, dict):
|
||||
return None
|
||||
tenant_key = str(identity.get("tenant_key") or "").strip()
|
||||
open_id = str(identity.get("open_id") or "").strip()
|
||||
if not tenant_key or not open_id:
|
||||
return None
|
||||
return tenant_key, open_id
|
||||
|
||||
|
||||
def identity_fence_required(payload: Any) -> bool:
|
||||
return isinstance(payload, dict) and bool(payload.get("identity_fence_required"))
|
||||
|
||||
|
||||
def _operations(payload: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
if payload.get("version") != 1:
|
||||
raise ValueError("Unsupported Feishu reply outbox payload version")
|
||||
operations = payload.get("operations")
|
||||
if not isinstance(operations, list) or not all(
|
||||
isinstance(operation, dict) for operation in operations
|
||||
):
|
||||
raise ValueError("Feishu reply outbox operations are invalid")
|
||||
return operations
|
||||
|
||||
|
||||
def _replace_image_placeholders(
|
||||
value: Any,
|
||||
image_keys: dict[str, str],
|
||||
) -> Any:
|
||||
if isinstance(value, dict):
|
||||
return {
|
||||
key: _replace_image_placeholders(item, image_keys)
|
||||
for key, item in value.items()
|
||||
}
|
||||
if isinstance(value, list):
|
||||
return [
|
||||
_replace_image_placeholders(item, image_keys)
|
||||
for item in value
|
||||
]
|
||||
if (
|
||||
isinstance(value, str)
|
||||
and value.startswith(_IMAGE_PLACEHOLDER_PREFIX)
|
||||
):
|
||||
image_key = image_keys.get(value)
|
||||
if not image_key:
|
||||
raise ValueError("Feishu reply image was not prepared")
|
||||
return image_key
|
||||
return value
|
||||
|
||||
|
||||
def _required_receive_id(
|
||||
receive_id: str | None,
|
||||
default_receive_id: str | None,
|
||||
) -> str:
|
||||
target = str(receive_id or default_receive_id or "").strip()
|
||||
if not target:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=FEISHU_RECEIVE_ID_MISSING,
|
||||
)
|
||||
return target
|
||||
Reference in New Issue
Block a user