mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-10-26 13:16:24 +00:00
* feat(channels): generalize IM group targets and add task/goal run notifications
- Generalize proactive group notification capability (proactive_group_notifications), binding validation, and scheduled task group selection across Feishu, QQ, and WeCom while keeping WeChat personal-only.
- Add QQ group active notification delivery via POST /v2/groups/{group_openid}/messages (msg_type=2) and WeCom group active notification delivery via WS Client.sendMessage(chatid, body) with bot_id:group:chatid workspace isolation.
- Generalize notification outbox sources via migration 0043_runtime_im_notices (source_kind, source_key, run_id, nullable task_id/task_run_id) with backward-compatible SQLite/PostgreSQL upgrades.
- Add opt-in ordinary task & goal run IM notifications (run_completed, run_failed, human_input_requested) on the shared outbox, delivery worker, renderer, and settings UI.
* fix(notifications): address review feedback and group/runtime notification edge cases
* test(migrations): update CURRENT_HEAD to 0043_runtime_im_notices across migration chain tests
* fix(channels): preserve group binding names and legacy authorization
Carry owner-chosen group names through existing connect state, keep private bindings out of legacy group fallback, and restore completion metadata retries. Cover real binding and worker finalization paths and synchronize notification docs.
---------
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2019 lines
98 KiB
Python
2019 lines
98 KiB
Python
"""Feishu/Lark channel — connects to Feishu via WebSocket (no public IP needed)."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import json
|
|
import logging
|
|
import re
|
|
import stat
|
|
import threading
|
|
import time
|
|
from collections import deque
|
|
from collections.abc import Callable, Coroutine
|
|
from concurrent.futures import CancelledError as FutureCancelledError
|
|
from concurrent.futures import Future
|
|
from dataclasses import dataclass, field
|
|
from typing import Any, Literal, TypeVar
|
|
|
|
from app.channels.base import Channel, ChannelStopTimeout, ChannelUnavailable, abort_connection_transport, shutdown_private_loop
|
|
from app.channels.commands import is_known_channel_command, strip_leading_mentions
|
|
from app.channels.connection_identity import attach_connection_identity, lookup_thread_id
|
|
from app.channels.feishu_card_interaction import (
|
|
ClarificationCardRequest,
|
|
PendingCardEntry,
|
|
PendingCardRegistry,
|
|
build_button_card,
|
|
)
|
|
from app.channels.message_bus import (
|
|
HUMAN_INPUT_RESPONSE_METADATA_KEY,
|
|
PENDING_CLARIFICATION_METADATA_KEY,
|
|
RESOLVED_FROM_PENDING_CLARIFICATION_METADATA_KEY,
|
|
InboundMessage,
|
|
InboundMessageType,
|
|
InboundReservation,
|
|
MessageBus,
|
|
OutboundMessage,
|
|
ResolvedAttachment,
|
|
)
|
|
from app.channels.notification_groups import is_feishu_notification_group, notification_group_display_name
|
|
from app.channels.sandbox_files import sync_file_to_thread_sandbox
|
|
from deerflow.config.paths import VIRTUAL_PATH_PREFIX, get_paths
|
|
from deerflow.runtime.user_context import get_effective_user_id
|
|
from deerflow.sandbox.sandbox_provider import get_sandbox_provider
|
|
from deerflow.uploads.manager import (
|
|
apply_upload_sandbox_permits,
|
|
claim_unique_filename,
|
|
normalize_filename,
|
|
write_upload_file_no_symlink,
|
|
)
|
|
|
|
logger = logging.getLogger(__name__)
|
|
PENDING_CLARIFICATION_TTL_SECONDS = 30 * 60
|
|
# stop() budget for the lark-oapi thread: ask its private loop to close the
|
|
# socket (bounded by the close timeout) and stop, then join the thread.
|
|
FEISHU_STOP_TIMEOUT_SECONDS = 5
|
|
FEISHU_WS_CLOSE_TIMEOUT_SECONDS = 2
|
|
_PENDING_CARD_METADATA_KEY = "_feishu_pending_card_id"
|
|
# Channel-store deadlines. ``_on_message`` runs on lark-oapi's private event loop,
|
|
# which also carries the SDK's websocket reads, pongs and its 120s ping loop:
|
|
# every second the bridge blocks that loop is a second the connection cannot
|
|
# service keepalives, so the bound is a few seconds (one indexed SELECT per
|
|
# candidate), not the database's 30s command timeout. The deadline lives on the
|
|
# Gateway-loop side (``asyncio.wait_for`` around the store calls, which actually
|
|
# cancels them); the bridge wait only adds slack for a stalled Gateway loop.
|
|
FEISHU_STORE_LOOKUP_TIMEOUT_SECONDS = 3.0
|
|
FEISHU_STORE_BRIDGE_TIMEOUT_SECONDS = FEISHU_STORE_LOOKUP_TIMEOUT_SECONDS + 1.0
|
|
# A lookup that missed its deadline (or failed) is *unresolved*, never a miss:
|
|
# the message is deferred to the Gateway loop, which retries with backoff up to
|
|
# the database's own command budget and then drops it rather than opening a new
|
|
# conversation for a reply whose mapping the database could still have served.
|
|
# The budget runs from the message's arrival, not from when it reaches its
|
|
# chat's queue head, and every deferred message holds a bus intake reservation
|
|
# taken before routing, so the deferred backlog is bounded by
|
|
# ``inbound_queue_maxsize`` exactly like live messages (a message that cannot be
|
|
# admitted is dropped with a warning, never queued).
|
|
FEISHU_STORE_RESOLVE_TOTAL_BUDGET_SECONDS = 30.0
|
|
FEISHU_STORE_RESOLVE_RETRY_BACKOFF_SECONDS = 1.0
|
|
FEISHU_STORE_RESOLVE_RETRY_MAX_BACKOFF_SECONDS = 8.0
|
|
_T = TypeVar("_T")
|
|
|
|
|
|
class StoreLookupUnresolved(Exception):
|
|
"""A channel-store call produced no answer: deadline, store error, no Gateway loop, or channel stop.
|
|
|
|
Distinct from a *miss* (the store answered "no mapping"): an unresolved lookup
|
|
must never route a message as if the mapping did not exist.
|
|
"""
|
|
|
|
def __init__(self, reason: str, *, future: Future[Any] | None = None) -> None:
|
|
super().__init__(reason)
|
|
self.reason = reason
|
|
# The still-running bridge future when the lark-thread wait gave up before
|
|
# the coroutine's own deadline; the deferred path awaits its outcome first.
|
|
self.future = future
|
|
|
|
|
|
@dataclass(slots=True)
|
|
class _ParsedMessage:
|
|
"""The content of one lark message event, independent of how it is routed."""
|
|
|
|
chat_id: str
|
|
msg_id: str
|
|
sender_id: str
|
|
root_id: str | None
|
|
parent_id: str | None
|
|
feishu_thread_id: str | None
|
|
chat_type: str | None
|
|
text: str
|
|
files: list[dict[str, Any]]
|
|
msg_type: InboundMessageType = InboundMessageType.CHAT
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class _Routing:
|
|
"""Where a parsed message goes: resolved (stored mapping), plain miss, or pending clarification."""
|
|
|
|
topic_id: str | None
|
|
from_stored_mapping: bool
|
|
from_pending: bool
|
|
clarification_card_id: str | None = None
|
|
|
|
|
|
@dataclass(slots=True)
|
|
class _RoutingAttempt:
|
|
"""Retry state for one message: once a pending clarification is consumed the route is decided.
|
|
|
|
``reservation`` is the bus intake slot taken before routing; it travels with
|
|
the message into dispatch (committed there) or is released on every drop.
|
|
``arrived_at`` anchors the per-message retry budget.
|
|
"""
|
|
|
|
parsed: _ParsedMessage
|
|
reservation: InboundReservation | None = None
|
|
arrived_at: float = field(default_factory=time.monotonic)
|
|
pending: dict[str, Any] | None = None
|
|
attempted: bool = False
|
|
|
|
def release_reservation(self) -> None:
|
|
if self.reservation is not None:
|
|
self.reservation.release()
|
|
self.reservation = None
|
|
|
|
|
|
FEISHU_INBOUND_BATCH_WINDOW_SECONDS = 0.75
|
|
FEISHU_MAX_INBOUND_FILE_BYTES = 20_000_000
|
|
SOURCE_PREVIEW_METADATA_KEY = "feishu_source_preview"
|
|
|
|
|
|
def _is_feishu_command(text: str) -> bool:
|
|
return is_known_channel_command(text)
|
|
|
|
|
|
class FeishuChannel(Channel):
|
|
"""Feishu/Lark IM channel using the ``lark-oapi`` WebSocket client.
|
|
|
|
Configuration keys (in ``config.yaml`` under ``channels.feishu``):
|
|
- ``app_id``: Feishu app ID.
|
|
- ``app_secret``: Feishu app secret.
|
|
- ``verification_token``: (optional) Event verification token.
|
|
|
|
The channel uses WebSocket long-connection mode so no public IP is required.
|
|
|
|
Message flow:
|
|
1. User sends a message → bot adds "OK" emoji reaction
|
|
2. Bot replies with a card: "Working on it......"
|
|
3. Agent processes the message and returns a result
|
|
4. Bot updates the card with the result
|
|
5. Bot adds "DONE" emoji reaction to the original message
|
|
"""
|
|
|
|
def __init__(self, bus: MessageBus, config: dict[str, Any]) -> None:
|
|
super().__init__(name="feishu", bus=bus, config=config)
|
|
self._thread: threading.Thread | None = None
|
|
self._main_loop: asyncio.AbstractEventLoop | None = None
|
|
# The SDK thread's private loop and client, published under _ws_lock once
|
|
# constructed so stop() can shut them down from the Gateway loop.
|
|
self._ws_loop: asyncio.AbstractEventLoop | None = None
|
|
self._ws_client: Any = None
|
|
self._ws_lock = threading.Lock()
|
|
self._api_client = None
|
|
self._CreateMessageReactionRequest = None
|
|
self._CreateMessageReactionRequestBody = None
|
|
self._Emoji = None
|
|
self._PatchMessageRequest = None
|
|
self._PatchMessageRequestBody = None
|
|
self._background_tasks: set[asyncio.Task] = set()
|
|
self._running_card_ids: dict[str, str] = {}
|
|
self._running_card_tasks: dict[str, asyncio.Task] = {}
|
|
self._pending_clarifications: dict[tuple[str, str], list[dict[str, Any]]] = {}
|
|
# Interactive clarification cards (button answers). Off by default: the
|
|
# feature needs a one-time console subscription to card.action.trigger.
|
|
self._card_interaction_enabled = bool(config.get("card_interaction_enabled", False))
|
|
self._card_registry = PendingCardRegistry()
|
|
self._CallBackToast = None
|
|
self._P2CardActionTriggerResponse = None
|
|
self._pending_inbound_batches: dict[tuple[str, str], dict[str, Any]] = {}
|
|
self._CreateFileRequest = None
|
|
self._CreateFileRequestBody = None
|
|
self._CreateImageRequest = None
|
|
self._CreateImageRequestBody = None
|
|
self._GetMessageResourceRequest = None
|
|
self._GetChatRequest = None
|
|
self._thread_lock = threading.Lock()
|
|
# Per-chat FIFO of messages whose routing is deferred to the Gateway loop
|
|
# (unresolved store lookups); guarded by ``_thread_lock``. A chat with a
|
|
# queue has one drain task owning that deque.
|
|
self._deferred_routing: dict[str, deque[_RoutingAttempt]] = {}
|
|
|
|
@staticmethod
|
|
def _non_empty_str(value: Any) -> str | None:
|
|
if isinstance(value, str) and value.strip():
|
|
return value.strip()
|
|
return None
|
|
|
|
@staticmethod
|
|
def _pending_key(chat_id: str, user_id: str) -> tuple[str, str]:
|
|
return (chat_id, user_id)
|
|
|
|
@staticmethod
|
|
def _should_include_source_preview(
|
|
*,
|
|
chat_type: str | None,
|
|
root_id: str | None,
|
|
parent_id: str | None,
|
|
thread_id: str | None,
|
|
) -> bool:
|
|
if chat_type == "p2p":
|
|
return False
|
|
return bool(root_id or parent_id or thread_id)
|
|
|
|
@staticmethod
|
|
def _compact_source_preview(text: str) -> str | None:
|
|
stripped = text.strip()
|
|
if not stripped:
|
|
return None
|
|
|
|
lines = [line.strip() for line in stripped.splitlines() if line.strip()]
|
|
if not lines:
|
|
return None
|
|
preview = "\n".join(lines[:3])
|
|
if len(preview) > 240:
|
|
preview = preview[:237].rstrip() + "..."
|
|
return preview
|
|
|
|
@classmethod
|
|
def _compose_card_text(cls, text: str, metadata: dict[str, Any] | None = None) -> str:
|
|
preview = None
|
|
if isinstance(metadata, dict):
|
|
raw_preview = metadata.get(SOURCE_PREVIEW_METADATA_KEY)
|
|
if isinstance(raw_preview, str) and raw_preview.strip():
|
|
preview = raw_preview.strip()
|
|
if not preview:
|
|
return text
|
|
|
|
quoted_preview = "\n".join(f"> {line}" for line in preview.splitlines())
|
|
return f"{quoted_preview}\n\n{text}"
|
|
|
|
@property
|
|
def supports_streaming(self) -> bool:
|
|
return True
|
|
|
|
@property
|
|
def is_running(self) -> bool:
|
|
if not self._running:
|
|
return False
|
|
return self._thread is not None and self._thread.is_alive()
|
|
|
|
def _build_event_handler(self, lark):
|
|
builder = (
|
|
lark.EventDispatcherHandler.builder("", "")
|
|
.register_p2_im_message_receive_v1(self._on_message)
|
|
.register_p2_im_message_message_read_v1(self._on_ignored_message_event)
|
|
.register_p2_im_message_reaction_created_v1(self._on_ignored_message_event)
|
|
.register_p2_im_message_reaction_deleted_v1(self._on_ignored_message_event)
|
|
.register_p2_im_message_recalled_v1(self._on_ignored_message_event)
|
|
)
|
|
if self._card_interaction_enabled:
|
|
# Card clicks arrive as WS EVENT frames routed through the SDK's
|
|
# callback processor map (verified against lark-oapi source); the
|
|
# synchronous return value becomes the click's toast ack.
|
|
builder = builder.register_p2_card_action_trigger(self._on_card_action)
|
|
return builder.build()
|
|
|
|
async def start(self) -> None:
|
|
if self._running:
|
|
return
|
|
|
|
try:
|
|
import lark_oapi as lark
|
|
from lark_oapi.api.im.v1 import (
|
|
CreateFileRequest,
|
|
CreateFileRequestBody,
|
|
CreateImageRequest,
|
|
CreateImageRequestBody,
|
|
CreateMessageReactionRequest,
|
|
CreateMessageReactionRequestBody,
|
|
CreateMessageRequest,
|
|
CreateMessageRequestBody,
|
|
Emoji,
|
|
GetChatRequest,
|
|
GetMessageResourceRequest,
|
|
PatchMessageRequest,
|
|
PatchMessageRequestBody,
|
|
ReplyMessageRequest,
|
|
ReplyMessageRequestBody,
|
|
)
|
|
except ImportError:
|
|
logger.error("lark-oapi is not installed. Install it with: uv add lark-oapi")
|
|
return
|
|
|
|
self._lark = lark
|
|
from lark_oapi.event.callback.model.p2_card_action_trigger import (
|
|
CallBackToast,
|
|
P2CardActionTriggerResponse,
|
|
)
|
|
|
|
self._CallBackToast = CallBackToast
|
|
self._P2CardActionTriggerResponse = P2CardActionTriggerResponse
|
|
self._CreateMessageRequest = CreateMessageRequest
|
|
self._CreateMessageRequestBody = CreateMessageRequestBody
|
|
self._ReplyMessageRequest = ReplyMessageRequest
|
|
self._ReplyMessageRequestBody = ReplyMessageRequestBody
|
|
self._CreateMessageReactionRequest = CreateMessageReactionRequest
|
|
self._CreateMessageReactionRequestBody = CreateMessageReactionRequestBody
|
|
self._Emoji = Emoji
|
|
self._PatchMessageRequest = PatchMessageRequest
|
|
self._PatchMessageRequestBody = PatchMessageRequestBody
|
|
self._CreateFileRequest = CreateFileRequest
|
|
self._CreateFileRequestBody = CreateFileRequestBody
|
|
self._CreateImageRequest = CreateImageRequest
|
|
self._CreateImageRequestBody = CreateImageRequestBody
|
|
self._GetMessageResourceRequest = GetMessageResourceRequest
|
|
self._GetChatRequest = GetChatRequest
|
|
|
|
app_id = self.config.get("app_id", "")
|
|
app_secret = self.config.get("app_secret", "")
|
|
domain = self.config.get("domain", "https://open.feishu.cn")
|
|
|
|
if not app_id or not app_secret:
|
|
logger.error("Feishu channel requires app_id and app_secret")
|
|
return
|
|
|
|
self._api_client = lark.Client.builder().app_id(app_id).app_secret(app_secret).domain(domain).build()
|
|
logger.info("[Feishu] using domain: %s", domain)
|
|
self._main_loop = asyncio.get_event_loop()
|
|
|
|
self._open_threadsafe_future_intake()
|
|
self._running = True
|
|
self.bus.subscribe_outbound(self._on_outbound)
|
|
|
|
# Both ws.Client construction and start() must happen in a dedicated
|
|
# thread with its own event loop. lark-oapi caches the running loop
|
|
# at construction time and later calls loop.run_until_complete(),
|
|
# which conflicts with an already-running uvloop.
|
|
self._thread = threading.Thread(
|
|
target=self._run_ws,
|
|
args=(app_id, app_secret, domain),
|
|
daemon=True,
|
|
)
|
|
self._thread.start()
|
|
logger.info("Feishu channel started")
|
|
|
|
def _run_ws(self, app_id: str, app_secret: str, domain: str) -> None:
|
|
"""Construct and run the lark WS client in a thread with a fresh event loop.
|
|
|
|
The lark-oapi SDK captures a module-level event loop at import time
|
|
(``lark_oapi.ws.client.loop``). When uvicorn uses uvloop, that
|
|
captured loop is the *main* thread's uvloop — which is already
|
|
running, so ``loop.run_until_complete()`` inside ``Client.start()``
|
|
raises ``RuntimeError``.
|
|
|
|
We work around this by creating a plain asyncio event loop for this
|
|
thread and patching the SDK's module-level reference before calling
|
|
``start()``.
|
|
|
|
``stop()`` ends this thread through ``_request_ws_shutdown``: lark-oapi
|
|
1.7 has no public stop, so ``start()`` only returns once this loop is
|
|
stopped from a callback scheduled on it — and only after that callback
|
|
disarmed ``_auto_reconnect``, because otherwise ``start()`` swallows the
|
|
interrupted ``run_until_complete`` in its reconnect branch and dials
|
|
again.
|
|
"""
|
|
loop = asyncio.new_event_loop()
|
|
asyncio.set_event_loop(loop)
|
|
try:
|
|
import lark_oapi as lark
|
|
import lark_oapi.ws.client as _ws_client_mod
|
|
|
|
# Replace the SDK's module-level loop so Client.start() uses
|
|
# this thread's (non-running) event loop instead of the main
|
|
# thread's uvloop.
|
|
_ws_client_mod.loop = loop
|
|
|
|
event_handler = self._build_event_handler(lark)
|
|
ws_client = lark.ws.Client(
|
|
app_id=app_id,
|
|
app_secret=app_secret,
|
|
event_handler=event_handler,
|
|
log_level=lark.LogLevel.INFO,
|
|
domain=domain,
|
|
)
|
|
with self._ws_lock:
|
|
if not self._running:
|
|
# stop() ran before the client existed: never dial.
|
|
return
|
|
self._ws_loop = loop
|
|
self._ws_client = ws_client
|
|
ws_client.start()
|
|
except Exception:
|
|
if self._running:
|
|
logger.exception("Feishu WebSocket error")
|
|
else:
|
|
logger.debug("[Feishu] SDK loop exited after stop", exc_info=True)
|
|
self._running = False
|
|
finally:
|
|
with self._ws_lock:
|
|
if self._ws_loop is loop:
|
|
self._ws_loop = None
|
|
self._ws_client = None
|
|
shutdown_private_loop(loop)
|
|
|
|
def _request_ws_shutdown(self) -> None:
|
|
"""Ask the SDK thread to close its socket and stop its loop (non-blocking)."""
|
|
with self._ws_lock:
|
|
loop = self._ws_loop
|
|
ws_client = self._ws_client
|
|
if loop is None:
|
|
return
|
|
try:
|
|
loop.call_soon_threadsafe(self._begin_ws_shutdown, loop, ws_client)
|
|
except RuntimeError:
|
|
# The loop closed between the snapshot and the call: the thread is
|
|
# already on its way out and the join below observes that.
|
|
logger.debug("[Feishu] SDK loop already closed before shutdown could be scheduled")
|
|
|
|
def _begin_ws_shutdown(self, loop: asyncio.AbstractEventLoop, ws_client: Any) -> None:
|
|
loop.create_task(self._shutdown_ws(loop, ws_client))
|
|
|
|
async def _shutdown_ws(self, loop: asyncio.AbstractEventLoop, ws_client: Any) -> None:
|
|
"""Runs on the SDK loop: disarm reconnects, close the socket, stop the loop."""
|
|
try:
|
|
if ws_client is not None:
|
|
# Must precede any await: once the loop stops, start() lands in
|
|
# its ``except Exception`` branch and consults this flag.
|
|
ws_client._auto_reconnect = False
|
|
deadline = loop.time() + FEISHU_WS_CLOSE_TIMEOUT_SECONDS
|
|
# _disconnect() clears _conn in its finally (also when cancelled
|
|
# mid-acquire), so take the handle first — once any in-flight
|
|
# dial has settled — and release the socket here if the close
|
|
# handshake does not finish, rather than whenever the
|
|
# connection object happens to be garbage-collected.
|
|
connection = await self._settled_ws_connection(ws_client, deadline)
|
|
disconnect = getattr(ws_client, "_disconnect", None)
|
|
if disconnect is not None:
|
|
try:
|
|
async with asyncio.timeout_at(deadline):
|
|
await disconnect()
|
|
except Exception:
|
|
logger.debug("[Feishu] WebSocket close did not complete cleanly", exc_info=True)
|
|
if abort_connection_transport(connection):
|
|
logger.warning("[Feishu] WebSocket close handshake did not finish within %ss; transport aborted", FEISHU_WS_CLOSE_TIMEOUT_SECONDS)
|
|
await asyncio.sleep(0) # let connection_lost run before the loop stops
|
|
except asyncio.CancelledError:
|
|
# The thread is already draining this loop; leave it to that.
|
|
raise
|
|
loop.stop()
|
|
|
|
@staticmethod
|
|
async def _settled_ws_connection(ws_client: Any, deadline: float) -> Any:
|
|
"""Return the SDK's live connection once any in-flight ``_connect()`` has settled.
|
|
|
|
lark holds ``_lock`` across the whole dial and assigns ``_conn`` under
|
|
it, so a stop that lands mid-handshake would otherwise snapshot
|
|
``None`` and leave the socket the dial produces to the SDK's cancelled
|
|
close. Waiting for the lock is bounded by the close deadline.
|
|
"""
|
|
lock = getattr(ws_client, "_lock", None)
|
|
if lock is None:
|
|
return getattr(ws_client, "_conn", None)
|
|
try:
|
|
async with asyncio.timeout_at(deadline):
|
|
await lock.acquire()
|
|
except TimeoutError:
|
|
return getattr(ws_client, "_conn", None)
|
|
try:
|
|
return getattr(ws_client, "_conn", None)
|
|
finally:
|
|
lock.release()
|
|
|
|
def _on_ignored_message_event(self, event) -> None:
|
|
logger.debug("[Feishu] ignoring non-content message event: %s", type(event).__name__)
|
|
|
|
async def stop(self) -> None:
|
|
self._running = False
|
|
self.bus.unsubscribe_outbound(self._on_outbound)
|
|
# Signal the SDK thread first so its loop winds down while the
|
|
# Gateway-side drain below runs.
|
|
self._request_ws_shutdown()
|
|
await self._close_and_drain_threadsafe_futures()
|
|
|
|
background_tasks = tuple(self._background_tasks)
|
|
running_card_tasks = tuple(self._running_card_tasks.values())
|
|
for task in background_tasks:
|
|
task.cancel()
|
|
for task in running_card_tasks:
|
|
task.cancel()
|
|
if background_tasks or running_card_tasks:
|
|
await asyncio.gather(*background_tasks, *running_card_tasks, return_exceptions=True)
|
|
self._background_tasks.clear()
|
|
self._running_card_tasks.clear()
|
|
if self._thread:
|
|
# The thread exits once its loop has stopped; keep the join off the
|
|
# event loop because a wedged SDK call can still use up the budget.
|
|
thread = self._thread
|
|
await asyncio.to_thread(thread.join, timeout=FEISHU_STOP_TIMEOUT_SECONDS)
|
|
if thread.is_alive():
|
|
raise ChannelStopTimeout("Feishu SDK thread is still running after stop timeout")
|
|
if self._thread is thread:
|
|
self._thread = None
|
|
with self._ws_lock:
|
|
self._ws_loop = None
|
|
self._ws_client = None
|
|
logger.info("Feishu channel stopped")
|
|
|
|
async def send(self, msg: OutboundMessage, *, _max_retries: int = 3) -> None:
|
|
if not self._api_client:
|
|
logger.warning("[Feishu] send called but no api_client available")
|
|
return
|
|
|
|
logger.info(
|
|
"[Feishu] sending reply: chat_id=%s, thread_ts=%s, text_len=%d",
|
|
msg.chat_id,
|
|
msg.thread_ts,
|
|
len(msg.text),
|
|
)
|
|
|
|
await self._send_with_retry(
|
|
lambda: self._send_card_message(msg),
|
|
max_retries=_max_retries,
|
|
log_prefix="[Feishu]",
|
|
)
|
|
|
|
async def send_notification(self, *, target: str, text_markdown: str) -> None:
|
|
"""Send a static card to a bound user's open ID and await its ACK."""
|
|
await self._send_notification_card(receive_id=target, receive_id_type="open_id", text_markdown=text_markdown)
|
|
|
|
async def send_notification_for_owner(self, *, owner_user_id: str | None, target: str, text_markdown: str) -> None:
|
|
"""Prefer a verified private chat when direct user delivery is restricted."""
|
|
if self._connection_repo is not None and owner_user_id:
|
|
try:
|
|
connections = await self._connection_repo.list_connections(owner_user_id)
|
|
except Exception as exc:
|
|
raise ChannelUnavailable("Feishu notification binding lookup is unavailable") from exc
|
|
matching_binding = False
|
|
for connection in connections:
|
|
if connection.get("owner_user_id") != owner_user_id or connection.get("provider") != self.name or connection.get("status") != "connected" or connection.get("external_account_id") != target:
|
|
continue
|
|
matching_binding = True
|
|
metadata = connection.get("metadata")
|
|
if not isinstance(metadata, dict) or metadata.get("chat_type") != "p2p":
|
|
continue
|
|
chat_id = self._non_empty_str(metadata.get("chat_id"))
|
|
if chat_id and chat_id == connection.get("workspace_id"):
|
|
await self._send_notification_card(receive_id=chat_id, receive_id_type="chat_id", text_markdown=text_markdown)
|
|
return
|
|
if not matching_binding:
|
|
raise RuntimeError("Feishu notification identity is no longer connected")
|
|
# Legacy and group bindings do not prove that their chat is private.
|
|
await self.send_notification(target=target, text_markdown=text_markdown)
|
|
|
|
async def _send_notification_card(self, *, receive_id: str, receive_id_type: Literal["open_id", "chat_id"], text_markdown: str) -> None:
|
|
"""Send once and wait for the SDK ACK.
|
|
|
|
The notification outbox owns retries. Transport failures park its row;
|
|
a platform rejection remains a counted delivery attempt.
|
|
"""
|
|
if not self.is_running or not self._api_client:
|
|
raise ChannelUnavailable("Feishu channel is not running")
|
|
|
|
from requests.exceptions import ConnectionError as RequestsConnectionError
|
|
from requests.exceptions import Timeout as RequestsTimeout
|
|
|
|
request = (
|
|
self._CreateMessageRequest.builder()
|
|
.receive_id_type(receive_id_type)
|
|
.request_body(self._CreateMessageRequestBody.builder().receive_id(receive_id).msg_type("interactive").content(self._build_card_content(text_markdown)).build())
|
|
.build()
|
|
)
|
|
try:
|
|
response = await asyncio.to_thread(self._api_client.im.v1.message.create, request)
|
|
except (RequestsConnectionError, RequestsTimeout, ConnectionError, TimeoutError, OSError) as exc:
|
|
raise ChannelUnavailable("Feishu notification transport is unavailable") from exc
|
|
if not response.success():
|
|
raise RuntimeError(f"Feishu notification rejected: code={response.code}")
|
|
|
|
async def send_group_notification_for_owner(self, *, owner_user_id: str | None, target: str, text_markdown: str) -> None:
|
|
if self._connection_repo is None or not owner_user_id:
|
|
raise ChannelUnavailable("Feishu notification binding lookup is unavailable")
|
|
try:
|
|
connections = await self._connection_repo.list_connections(owner_user_id)
|
|
except Exception as exc:
|
|
raise ChannelUnavailable("Feishu notification binding lookup is unavailable") from exc
|
|
for connection in connections:
|
|
if connection.get("owner_user_id") != owner_user_id or connection.get("id") != target:
|
|
continue
|
|
if is_feishu_notification_group(provider=connection.get("provider"), status=connection.get("status"), workspace_id=connection.get("workspace_id"), metadata=connection.get("metadata")):
|
|
await self._send_notification_card(receive_id=connection["workspace_id"], receive_id_type="chat_id", text_markdown=text_markdown)
|
|
return
|
|
raise RuntimeError("Feishu notification group is no longer connected")
|
|
|
|
async def send_file(self, msg: OutboundMessage, attachment: ResolvedAttachment) -> bool:
|
|
if not self._api_client:
|
|
return False
|
|
|
|
# Check size limits (image: 10MB, file: 30MB)
|
|
if attachment.is_image and attachment.size > 10 * 1024 * 1024:
|
|
logger.warning("[Feishu] image too large (%d bytes), skipping: %s", attachment.size, attachment.filename)
|
|
return False
|
|
if not attachment.is_image and attachment.size > 30 * 1024 * 1024:
|
|
logger.warning("[Feishu] file too large (%d bytes), skipping: %s", attachment.size, attachment.filename)
|
|
return False
|
|
|
|
try:
|
|
if attachment.is_image:
|
|
file_key = await self._upload_image(attachment.actual_path)
|
|
msg_type = "image"
|
|
content = json.dumps({"image_key": file_key})
|
|
else:
|
|
file_key = await self._upload_file(attachment.actual_path, attachment.filename)
|
|
msg_type = "file"
|
|
content = json.dumps({"file_key": file_key})
|
|
|
|
if msg.thread_ts:
|
|
request = self._ReplyMessageRequest.builder().message_id(msg.thread_ts).request_body(self._ReplyMessageRequestBody.builder().msg_type(msg_type).content(content).build()).build()
|
|
response = await asyncio.to_thread(self._api_client.im.v1.message.reply, request)
|
|
else:
|
|
request = self._CreateMessageRequest.builder().receive_id_type("chat_id").request_body(self._CreateMessageRequestBody.builder().receive_id(msg.chat_id).msg_type(msg_type).content(content).build()).build()
|
|
response = await asyncio.to_thread(self._api_client.im.v1.message.create, request)
|
|
if not response.success():
|
|
raise RuntimeError(f"Feishu file send failed: code={response.code}, msg={response.msg}, log_id={response.get_log_id()}")
|
|
|
|
logger.info("[Feishu] file sent: %s (type=%s)", attachment.filename, msg_type)
|
|
return True
|
|
except Exception:
|
|
logger.exception("[Feishu] failed to upload/send file: %s", attachment.filename)
|
|
return False
|
|
|
|
def _upload_image_sync(self, path):
|
|
with open(str(path), "rb") as f:
|
|
request = self._CreateImageRequest.builder().request_body(self._CreateImageRequestBody.builder().image_type("message").image(f).build()).build()
|
|
return self._api_client.im.v1.image.create(request)
|
|
|
|
async def _upload_image(self, path) -> str:
|
|
"""Upload an image to Feishu and return the image_key."""
|
|
response = await asyncio.to_thread(self._upload_image_sync, path)
|
|
if not response.success():
|
|
raise RuntimeError(f"Feishu image upload failed: code={response.code}, msg={response.msg}")
|
|
return response.data.image_key
|
|
|
|
def _upload_file_sync(self, path, filename: str, file_type: str):
|
|
with open(str(path), "rb") as f:
|
|
request = self._CreateFileRequest.builder().request_body(self._CreateFileRequestBody.builder().file_type(file_type).file_name(filename).file(f).build()).build()
|
|
return self._api_client.im.v1.file.create(request)
|
|
|
|
async def _upload_file(self, path, filename: str) -> str:
|
|
"""Upload a file to Feishu and return the file_key."""
|
|
suffix = path.suffix.lower() if hasattr(path, "suffix") else ""
|
|
if suffix in (".xls", ".xlsx", ".csv"):
|
|
file_type = "xls"
|
|
elif suffix in (".ppt", ".pptx"):
|
|
file_type = "ppt"
|
|
elif suffix == ".pdf":
|
|
file_type = "pdf"
|
|
elif suffix in (".doc", ".docx"):
|
|
file_type = "doc"
|
|
else:
|
|
file_type = "stream"
|
|
|
|
response = await asyncio.to_thread(self._upload_file_sync, path, filename, file_type)
|
|
if not response.success():
|
|
raise RuntimeError(f"Feishu file upload failed: code={response.code}, msg={response.msg}")
|
|
return response.data.file_key
|
|
|
|
async def receive_file(self, msg: InboundMessage, thread_id: str, *, user_id: str | None = None) -> InboundMessage:
|
|
"""Download a Feishu file into the thread uploads directory.
|
|
|
|
Returns the sandbox virtual path when the image is persisted successfully.
|
|
"""
|
|
if not msg.thread_ts:
|
|
logger.warning("[Feishu] received file message without thread_ts, cannot associate with conversation: %s", msg)
|
|
return msg
|
|
files = msg.files
|
|
if not files:
|
|
logger.warning("[Feishu] received message with no files: %s", msg)
|
|
return msg
|
|
text = msg.text
|
|
search_from = 0
|
|
|
|
def replace_next_placeholder(placeholder: str, replacement: str) -> None:
|
|
nonlocal search_from, text
|
|
idx = text.find(placeholder, search_from)
|
|
if idx < 0:
|
|
return
|
|
text = f"{text[:idx]}{replacement}{text[idx + len(placeholder) :]}"
|
|
search_from = idx + len(replacement)
|
|
|
|
for file in files:
|
|
if file.get("image_key"):
|
|
virtual_path = await self._receive_single_file(msg.thread_ts, file["image_key"], "image", thread_id, user_id=user_id)
|
|
replace_next_placeholder("[image]", virtual_path)
|
|
elif file.get("file_key"):
|
|
virtual_path = await self._receive_single_file(msg.thread_ts, file["file_key"], "file", thread_id, user_id=user_id)
|
|
replace_next_placeholder("[file]", virtual_path)
|
|
msg.text = text
|
|
return msg
|
|
|
|
async def _receive_single_file(
|
|
self,
|
|
message_id: str,
|
|
file_key: str,
|
|
type: Literal["image", "file"],
|
|
thread_id: str,
|
|
*,
|
|
user_id: str | None = None,
|
|
) -> str:
|
|
request = self._GetMessageResourceRequest.builder().message_id(message_id).file_key(file_key).type(type).build()
|
|
|
|
def inner():
|
|
return self._api_client.im.v1.message_resource.get(request)
|
|
|
|
try:
|
|
response = await asyncio.to_thread(inner)
|
|
except Exception:
|
|
logger.exception("[Feishu] resource get request failed for resource_key=%s type=%s", file_key, type)
|
|
return f"Failed to obtain the [{type}]"
|
|
|
|
if not response.success():
|
|
logger.warning(
|
|
"[Feishu] resource get failed: resource_key=%s, type=%s, code=%s, msg=%s, log_id=%s ",
|
|
file_key,
|
|
type,
|
|
response.code,
|
|
response.msg,
|
|
response.get_log_id(),
|
|
)
|
|
return f"Failed to obtain the [{type}]"
|
|
|
|
image_stream = getattr(response, "file", None)
|
|
if image_stream is None:
|
|
logger.warning("[Feishu] resource get returned no file stream: resource_key=%s, type=%s", file_key, type)
|
|
return f"Failed to obtain the [{type}]"
|
|
|
|
try:
|
|
content = await asyncio.to_thread(image_stream.read, FEISHU_MAX_INBOUND_FILE_BYTES + 1)
|
|
except Exception:
|
|
logger.exception("[Feishu] failed to read resource stream: resource_key=%s, type=%s", file_key, type)
|
|
return f"Failed to obtain the [{type}]"
|
|
|
|
if isinstance(content, bytearray):
|
|
content = bytes(content)
|
|
elif isinstance(content, memoryview):
|
|
content = content.tobytes()
|
|
|
|
if not content:
|
|
logger.warning("[Feishu] empty resource content: resource_key=%s, type=%s", file_key, type)
|
|
return f"Failed to obtain the [{type}]"
|
|
if not isinstance(content, bytes):
|
|
logger.warning("[Feishu] resource stream returned non-bytes content: resource_key=%s, type=%s", file_key, type)
|
|
return f"Failed to obtain the [{type}]"
|
|
if len(content) > FEISHU_MAX_INBOUND_FILE_BYTES:
|
|
logger.warning(
|
|
"[Feishu] inbound resource exceeds 20 MB download limit, skipping: resource_key=%s, type=%s",
|
|
file_key,
|
|
type,
|
|
)
|
|
return f"Failed to obtain the [{type}]"
|
|
|
|
effective_user_id = user_id or get_effective_user_id()
|
|
paths = await asyncio.to_thread(get_paths)
|
|
|
|
default_ext = "png" if type == "image" else "bin"
|
|
key_token = re.sub(r"[^A-Za-z0-9_-]", "", file_key)[-12:] or "attachment"
|
|
fallback_name = f"feishu_{key_token}.{default_ext}"
|
|
raw_filename = getattr(response, "file_name", "") or fallback_name
|
|
try:
|
|
safe_filename = normalize_filename(raw_filename)
|
|
except (TypeError, ValueError):
|
|
safe_filename = fallback_name
|
|
|
|
def _persist():
|
|
paths.ensure_thread_dirs(thread_id, user_id=effective_user_id)
|
|
uploads_dir = paths.sandbox_uploads_dir(thread_id, user_id=effective_user_id).resolve()
|
|
with self._thread_lock:
|
|
seen = {entry.name for entry in uploads_dir.iterdir()}
|
|
while True:
|
|
unique_name = claim_unique_filename(safe_filename, seen)
|
|
try:
|
|
return write_upload_file_no_symlink(uploads_dir, unique_name, content, exclusive=True)
|
|
except FileExistsError:
|
|
continue
|
|
|
|
try:
|
|
resolved_target = await asyncio.to_thread(_persist)
|
|
# Root-written uploads are 0o600, which the non-root sandbox cannot
|
|
# read on a bind-mounted thread dir; grant group/other read like the
|
|
# channel manager's inbound-file path and the HTTP upload route.
|
|
await asyncio.to_thread(apply_upload_sandbox_permits, resolved_target, stat.S_IRGRP | stat.S_IROTH)
|
|
except (OSError, ValueError, RuntimeError):
|
|
logger.exception("[Feishu] failed to persist downloaded resource: %s, type=%s", safe_filename, type)
|
|
return f"Failed to obtain the [{type}]"
|
|
|
|
virtual_path = f"{VIRTUAL_PATH_PREFIX}/uploads/{resolved_target.name}"
|
|
|
|
try:
|
|
sandbox_provider = await asyncio.to_thread(get_sandbox_provider)
|
|
synced = await sync_file_to_thread_sandbox(
|
|
sandbox_provider,
|
|
thread_id=thread_id,
|
|
user_id=effective_user_id,
|
|
virtual_path=virtual_path,
|
|
content=content,
|
|
owner_prefix="feishu-upload",
|
|
release_on_last=True,
|
|
)
|
|
if not synced:
|
|
logger.warning("[Feishu] sandbox not found for thread_id=%s", thread_id)
|
|
return f"Failed to obtain the [{type}]"
|
|
except Exception:
|
|
logger.exception("[Feishu] failed to sync resource into non-local sandbox: %s", virtual_path)
|
|
return f"Failed to obtain the [{type}]"
|
|
|
|
logger.info("[Feishu] downloaded resource mapped: file_key=%s -> %s", file_key, virtual_path)
|
|
return virtual_path
|
|
|
|
# -- message formatting ------------------------------------------------
|
|
|
|
@staticmethod
|
|
def _build_card_content(text: str) -> str:
|
|
"""Build a Feishu interactive card with markdown content.
|
|
|
|
Feishu's interactive card format natively renders markdown, including
|
|
headers, bold/italic, code blocks, lists, and links.
|
|
"""
|
|
card = {
|
|
"config": {"wide_screen_mode": True, "update_multi": True},
|
|
"elements": [{"tag": "markdown", "content": text}],
|
|
}
|
|
return json.dumps(card)
|
|
|
|
# -- reaction helpers --------------------------------------------------
|
|
|
|
async def _add_reaction(self, message_id: str, emoji_type: str = "THUMBSUP") -> None:
|
|
"""Add an emoji reaction to a message."""
|
|
if not self._api_client or not self._CreateMessageReactionRequest:
|
|
return
|
|
try:
|
|
request = self._CreateMessageReactionRequest.builder().message_id(message_id).request_body(self._CreateMessageReactionRequestBody.builder().reaction_type(self._Emoji.builder().emoji_type(emoji_type).build()).build()).build()
|
|
response = await asyncio.to_thread(self._api_client.im.v1.message_reaction.create, request)
|
|
if not response.success():
|
|
logger.warning(
|
|
"[Feishu] reaction '%s' add failed for message %s: code=%s, msg=%s, log_id=%s",
|
|
emoji_type,
|
|
message_id,
|
|
response.code,
|
|
response.msg,
|
|
response.get_log_id(),
|
|
)
|
|
return
|
|
logger.info("[Feishu] reaction '%s' added to message %s", emoji_type, message_id)
|
|
except Exception:
|
|
logger.exception("[Feishu] failed to add reaction '%s' to message %s", emoji_type, message_id)
|
|
|
|
async def _reply_card(self, message_id: str, text: str, *, raw_card_content: bool = False) -> str | None:
|
|
"""Reply with text or serialized card JSON and return the card message ID."""
|
|
if not self._api_client:
|
|
return None
|
|
|
|
content = text if raw_card_content else self._build_card_content(text)
|
|
request = self._ReplyMessageRequest.builder().message_id(message_id).request_body(self._ReplyMessageRequestBody.builder().msg_type("interactive").content(content).build()).build()
|
|
response = await asyncio.to_thread(self._api_client.im.v1.message.reply, request)
|
|
if not response.success():
|
|
raise RuntimeError(f"Feishu card reply failed: code={response.code}, msg={response.msg}, log_id={response.get_log_id()}")
|
|
response_data = getattr(response, "data", None)
|
|
return getattr(response_data, "message_id", None)
|
|
|
|
async def _create_card(self, chat_id: str, text: str) -> None:
|
|
"""Create a new card message in the target chat."""
|
|
if not self._api_client:
|
|
return
|
|
|
|
content = self._build_card_content(text)
|
|
request = self._CreateMessageRequest.builder().receive_id_type("chat_id").request_body(self._CreateMessageRequestBody.builder().receive_id(chat_id).msg_type("interactive").content(content).build()).build()
|
|
response = await asyncio.to_thread(self._api_client.im.v1.message.create, request)
|
|
if not response.success():
|
|
raise RuntimeError(f"Feishu card creation failed: code={response.code}, msg={response.msg}, log_id={response.get_log_id()}")
|
|
|
|
async def _update_card(self, message_id: str, text: str, *, raw_card_content: bool = False) -> None:
|
|
"""Patch an existing card message in place.
|
|
|
|
``raw_card_content=True`` means *text* is already a serialized card JSON.
|
|
"""
|
|
if not self._api_client or not self._PatchMessageRequest:
|
|
return
|
|
|
|
content = text if raw_card_content else self._build_card_content(text)
|
|
request = self._PatchMessageRequest.builder().message_id(message_id).request_body(self._PatchMessageRequestBody.builder().content(content).build()).build()
|
|
response = await asyncio.to_thread(self._api_client.im.v1.message.patch, request)
|
|
if not response.success():
|
|
raise RuntimeError(f"Feishu card update failed: code={response.code}, msg={response.msg}, log_id={response.get_log_id()}")
|
|
|
|
def _track_background_task(self, task: asyncio.Task, *, name: str, msg_id: str) -> None:
|
|
"""Keep a strong reference to fire-and-forget tasks and surface errors."""
|
|
self._background_tasks.add(task)
|
|
task.add_done_callback(lambda done_task, task_name=name, mid=msg_id: self._finalize_background_task(done_task, task_name, mid))
|
|
|
|
def _finalize_background_task(self, task: asyncio.Task, name: str, msg_id: str) -> None:
|
|
self._background_tasks.discard(task)
|
|
self._log_task_error(task, name, msg_id)
|
|
|
|
async def _create_running_card(
|
|
self,
|
|
source_message_id: str,
|
|
text: str,
|
|
*,
|
|
metadata: dict[str, Any] | None = None,
|
|
) -> str | None:
|
|
"""Create the running card and cache its message ID when available."""
|
|
running_card_id = await self._reply_card(source_message_id, self._compose_card_text(text, metadata))
|
|
if running_card_id:
|
|
self._running_card_ids[source_message_id] = running_card_id
|
|
logger.info("[Feishu] running card created: source=%s card=%s", source_message_id, running_card_id)
|
|
else:
|
|
logger.warning("[Feishu] running card creation returned no message_id for source=%s, subsequent updates will fall back to new replies", source_message_id)
|
|
return running_card_id
|
|
|
|
def _ensure_running_card_started(
|
|
self,
|
|
source_message_id: str,
|
|
text: str = "thinking...",
|
|
*,
|
|
metadata: dict[str, Any] | None = None,
|
|
) -> asyncio.Task | None:
|
|
"""Start running-card creation once per source message."""
|
|
running_card_id = self._running_card_ids.get(source_message_id)
|
|
if running_card_id:
|
|
return None
|
|
|
|
running_card_task = self._running_card_tasks.get(source_message_id)
|
|
if running_card_task:
|
|
return running_card_task
|
|
|
|
running_card_task = asyncio.create_task(self._create_running_card(source_message_id, text, metadata=metadata))
|
|
self._running_card_tasks[source_message_id] = running_card_task
|
|
running_card_task.add_done_callback(lambda done_task, mid=source_message_id: self._finalize_running_card_task(mid, done_task))
|
|
return running_card_task
|
|
|
|
def _finalize_running_card_task(self, source_message_id: str, task: asyncio.Task) -> None:
|
|
if self._running_card_tasks.get(source_message_id) is task:
|
|
self._running_card_tasks.pop(source_message_id, None)
|
|
self._log_task_error(task, "create_running_card", source_message_id)
|
|
|
|
async def _ensure_running_card(
|
|
self,
|
|
source_message_id: str,
|
|
text: str = "thinking...",
|
|
*,
|
|
metadata: dict[str, Any] | None = None,
|
|
) -> str | None:
|
|
"""Ensure the in-thread running card exists and track its message ID."""
|
|
running_card_id = self._running_card_ids.get(source_message_id)
|
|
if running_card_id:
|
|
return running_card_id
|
|
|
|
running_card_task = self._ensure_running_card_started(
|
|
source_message_id,
|
|
text,
|
|
metadata=metadata,
|
|
)
|
|
if running_card_task is None:
|
|
return self._running_card_ids.get(source_message_id)
|
|
return await running_card_task
|
|
|
|
async def _send_running_reply(self, message_id: str, *, metadata: dict[str, Any] | None = None) -> None:
|
|
"""Reply to a message in-thread with a running card."""
|
|
try:
|
|
await self._ensure_running_card(message_id, metadata=metadata)
|
|
except Exception:
|
|
logger.exception("[Feishu] failed to send running reply for message %s", message_id)
|
|
|
|
async def _bind_card_thread(self, msg: OutboundMessage) -> str | None:
|
|
"""Bind answers to their thread without changing the chat's /new route."""
|
|
user_id = self._non_empty_str(msg.metadata.get("user_id"))
|
|
if not (user_id and msg.thread_id and msg.thread_ts):
|
|
return None
|
|
# Provider message IDs cannot address this namespace, and the alias
|
|
# is reused across a thread's cards without exposing callback tokens.
|
|
topic_id = f"feishu-card:{msg.thread_id}"
|
|
try:
|
|
if msg.connection_id:
|
|
if self._connection_repo is None or not msg.owner_user_id:
|
|
return None
|
|
await self._connection_repo.set_thread_id(connection_id=msg.connection_id, owner_user_id=msg.owner_user_id, provider=self.name, external_conversation_id=msg.chat_id, thread_id=msg.thread_id, external_topic_id=topic_id)
|
|
else:
|
|
store = self.config.get("channel_store")
|
|
if store is None:
|
|
return None
|
|
await store.set_thread_id(self.name, msg.chat_id, msg.thread_id, topic_id=topic_id, user_id=user_id)
|
|
except Exception:
|
|
logger.exception("[Feishu] cannot bind clarification card to thread %s; using text fallback", msg.thread_id)
|
|
return None
|
|
return topic_id
|
|
|
|
def _register_pending_card(self, msg: OutboundMessage, request: ClarificationCardRequest, card_message_id: str | None, *, topic_id: str) -> None:
|
|
"""Register a delivered button card so its clicks can be validated later."""
|
|
user_id = self._non_empty_str(msg.metadata.get("user_id"))
|
|
if not (user_id and msg.thread_id and card_message_id):
|
|
logger.warning("[Feishu] button card delivered without full binding context, clicks will fail closed: request_id=%s", request.request_id)
|
|
return
|
|
self._card_registry.register(
|
|
callback_token=request.callback_token,
|
|
request_id=request.request_id,
|
|
source=request.source,
|
|
thread_id=msg.thread_id,
|
|
chat_id=msg.chat_id,
|
|
originator_open_id=user_id,
|
|
options=request.options,
|
|
option_ids=request.option_ids,
|
|
card_message_id=card_message_id,
|
|
topic_id=topic_id,
|
|
connection_id=msg.connection_id,
|
|
owner_user_id=msg.owner_user_id,
|
|
)
|
|
|
|
async def _remember_card_delivery(self, msg: OutboundMessage, request: ClarificationCardRequest | None, card_message_id: str | None, *, topic_id: str | None) -> None:
|
|
# Admit callbacks immediately after the SDK returns. Auxiliary message
|
|
# aliases may involve slow storage, but the thread alias is already bound.
|
|
if request and topic_id:
|
|
self._register_pending_card(msg, request, card_message_id, topic_id=topic_id)
|
|
self._remember_pending_clarification(msg, card_message_id, topic_id=topic_id)
|
|
await self._remember_thread_mapping(msg, msg.thread_ts, card_message_id)
|
|
|
|
async def _send_card_message(self, msg: OutboundMessage) -> None:
|
|
"""Send or update the Feishu card tied to the current request."""
|
|
source_message_id = msg.thread_ts
|
|
# Option clarifications render as an interactive button card on the
|
|
# final outbound; everything else keeps the plain markdown card.
|
|
card_request = ClarificationCardRequest.from_metadata(msg.metadata) if (self._card_interaction_enabled and msg.is_final) else None
|
|
card_topic_id = await self._bind_card_thread(msg) if card_request else None
|
|
if card_topic_id is None:
|
|
card_request = None
|
|
card_text = build_button_card(card_request) if card_request else self._compose_card_text(msg.text, msg.metadata)
|
|
# Button cards arrive as serialized card JSON and bypass the markdown
|
|
# wrapper; the plain path keeps its exact historical call signature.
|
|
raw_card_kw: dict[str, bool] = {"raw_card_content": True} if card_request else {}
|
|
if source_message_id:
|
|
running_card_id = self._running_card_ids.get(source_message_id)
|
|
awaited_running_card_task = False
|
|
|
|
if not running_card_id:
|
|
running_card_task = self._running_card_tasks.get(source_message_id)
|
|
if running_card_task:
|
|
awaited_running_card_task = True
|
|
running_card_id = await running_card_task
|
|
|
|
if running_card_id:
|
|
try:
|
|
await self._update_card(running_card_id, card_text, **raw_card_kw)
|
|
except Exception:
|
|
if not msg.is_final:
|
|
raise
|
|
logger.exception(
|
|
"[Feishu] failed to patch running card %s, falling back to final reply",
|
|
running_card_id,
|
|
)
|
|
fallback_card_id = await self._reply_card(source_message_id, card_text, **raw_card_kw)
|
|
await self._remember_card_delivery(msg, card_request, fallback_card_id, topic_id=card_topic_id)
|
|
else:
|
|
await self._remember_card_delivery(msg, card_request, running_card_id, topic_id=card_topic_id)
|
|
logger.info("[Feishu] running card updated: source=%s card=%s", source_message_id, running_card_id)
|
|
elif msg.is_final:
|
|
final_card_id = await self._reply_card(
|
|
source_message_id,
|
|
card_text,
|
|
**raw_card_kw,
|
|
)
|
|
await self._remember_card_delivery(msg, card_request, final_card_id, topic_id=card_topic_id)
|
|
elif awaited_running_card_task:
|
|
logger.warning(
|
|
"[Feishu] running card task finished without message_id for source=%s, skipping duplicate non-final creation",
|
|
source_message_id,
|
|
)
|
|
else:
|
|
created_card_id = await self._ensure_running_card(
|
|
source_message_id,
|
|
msg.text,
|
|
metadata=msg.metadata,
|
|
)
|
|
await self._remember_thread_mapping(msg, source_message_id, created_card_id)
|
|
|
|
if msg.is_final:
|
|
self._running_card_ids.pop(source_message_id, None)
|
|
await self._add_reaction(source_message_id, "DONE")
|
|
return
|
|
|
|
await self._create_card(msg.chat_id, msg.text)
|
|
|
|
# -- internal ----------------------------------------------------------
|
|
|
|
async def _remember_thread_mapping(self, msg: OutboundMessage, *topic_ids: str | None) -> None:
|
|
store = self.config.get("channel_store")
|
|
if store is None or not msg.thread_id:
|
|
return
|
|
|
|
topic_id = self._non_empty_str(msg.metadata.get("topic_id"))
|
|
metadata_topic_ids = [msg.metadata.get("message_id"), topic_id]
|
|
if not (topic_id and topic_id.startswith("feishu-card:")):
|
|
metadata_topic_ids.extend([msg.metadata.get("root_id"), msg.metadata.get("parent_id"), msg.metadata.get("thread_id")])
|
|
# A reply to an older card stays in that card's thread. Its inherited
|
|
# root/parent must not overwrite the conversation switched by /new.
|
|
user_id = ""
|
|
raw_user_id = msg.metadata.get("user_id")
|
|
if isinstance(raw_user_id, str):
|
|
user_id = raw_user_id
|
|
|
|
seen: set[str] = set()
|
|
for topic_id in [*topic_ids, *metadata_topic_ids]:
|
|
topic_id = self._non_empty_str(topic_id)
|
|
if not topic_id or topic_id in seen:
|
|
continue
|
|
seen.add(topic_id)
|
|
try:
|
|
await store.set_thread_id(
|
|
self.name,
|
|
msg.chat_id,
|
|
msg.thread_id,
|
|
topic_id=topic_id,
|
|
user_id=user_id,
|
|
)
|
|
except Exception:
|
|
logger.exception("[Feishu] failed to remember thread mapping for topic_id=%s", topic_id)
|
|
|
|
def _run_store_call(self, make_coroutine: Callable[[], Coroutine[Any, Any, _T]], *, name: str, msg_id: Any) -> _T:
|
|
"""Run an async ``channel_store`` call from the synchronous lark callback.
|
|
|
|
The store is async (it may be the shared database table). ``_on_message``
|
|
runs on lark-oapi's thread, so the call is submitted to the Gateway loop
|
|
(``_main_loop``) through the tracked threadsafe-future helper (``stop()``
|
|
drains or cancels it) and awaited here for at most
|
|
``FEISHU_STORE_BRIDGE_TIMEOUT_SECONDS``.
|
|
|
|
That callback does run inside an event loop: ``_run_ws`` gives the SDK
|
|
thread its own loop and lark-oapi 1.5.5 calls ``_on_message``
|
|
synchronously from its async frame handler, so ``get_running_loop()``
|
|
succeeds here on every real message. Blocking that loop briefly is fine —
|
|
it is the SDK's private loop in its own thread, the one that used to block
|
|
on the JSON file rewrite the store did inline — because the coroutine runs
|
|
on the Gateway loop, not on it. The only wait that must be refused is one
|
|
issued from the Gateway loop itself (``get_running_loop() is _main_loop``):
|
|
it would block the loop that has to execute the submitted coroutine.
|
|
|
|
Every way of *not* getting an answer — no running Gateway loop, called on
|
|
the Gateway loop, channel stopping, bridge wait exhausted, cancelled by
|
|
stop, or the coroutine's own ``StoreLookupUnresolved`` — raises
|
|
``StoreLookupUnresolved`` so the caller can defer the message; a timed-out
|
|
wait is never reported as "no mapping". Other exceptions the coroutine
|
|
raised propagate unchanged.
|
|
"""
|
|
loop = self._main_loop
|
|
if loop is None or not loop.is_running():
|
|
logger.warning("[Feishu] main loop not running, cannot %s", name)
|
|
raise StoreLookupUnresolved("main loop not running")
|
|
try:
|
|
current = asyncio.get_running_loop()
|
|
except RuntimeError:
|
|
current = None
|
|
if current is loop:
|
|
logger.warning("[Feishu] %s requested on the Gateway loop itself; a blocking wait here would deadlock it, skipping the channel store call", name)
|
|
raise StoreLookupUnresolved("called on the Gateway loop")
|
|
future = self._submit_threadsafe_coroutine_future(make_coroutine(), loop, name=name, msg_id=msg_id)
|
|
if future is None:
|
|
logger.info("[Feishu] channel stopping, skipped %s", name)
|
|
raise StoreLookupUnresolved("channel stopping")
|
|
try:
|
|
return future.result(timeout=FEISHU_STORE_BRIDGE_TIMEOUT_SECONDS)
|
|
except TimeoutError:
|
|
# Not cancelled: the coroutine carries its own deadline and the
|
|
# deferred path awaits its outcome before retrying.
|
|
logger.warning("[Feishu] %s did not answer within %.0fs; deferring message %s", name, FEISHU_STORE_BRIDGE_TIMEOUT_SECONDS, msg_id)
|
|
raise StoreLookupUnresolved("bridge wait exhausted", future=future) from None
|
|
except FutureCancelledError:
|
|
logger.info("[Feishu] %s cancelled by channel stop", name)
|
|
raise StoreLookupUnresolved("cancelled by channel stop") from None
|
|
|
|
def _card_action_toast(self, content: str, *, toast_type: str = "info"):
|
|
"""Build the synchronous toast ack for a card click."""
|
|
toast = self._CallBackToast()
|
|
toast.type = toast_type
|
|
toast.content = content
|
|
resp = self._P2CardActionTriggerResponse()
|
|
resp.toast = toast
|
|
return resp
|
|
|
|
def _on_card_action(self, data):
|
|
"""Sync card-click callback on the lark SDK loop: validate, ack, dispatch.
|
|
|
|
Only validation and the toast ack happen on this thread; the state
|
|
change (terminal card update + hidden synthetic inbound) is dispatched
|
|
to the Gateway loop. Registry consumption is one-time, so a validated
|
|
click can never be replayed, and a non-originator click leaves the
|
|
entry intact for the real originator.
|
|
"""
|
|
try:
|
|
value = getattr(data.event.action, "value", None) or {}
|
|
callback_token = value.get("callback_token")
|
|
option_id = value.get("option_id")
|
|
option_value = value.get("option_value")
|
|
operator_open_id = getattr(data.event.operator, "open_id", None)
|
|
except AttributeError:
|
|
return self._card_action_toast("无法处理的卡片操作", toast_type="warning")
|
|
if not all(isinstance(item, str) and item for item in (callback_token, option_id, option_value, operator_open_id)):
|
|
return self._card_action_toast("无法处理的卡片操作", toast_type="warning")
|
|
|
|
entry, reason = self._card_registry.consume_if_originator(callback_token, operator_open_id, option_id=option_id, option_value=option_value)
|
|
if reason == "not_originator":
|
|
return self._card_action_toast("仅提问发起人可操作", toast_type="warning")
|
|
if entry is None:
|
|
return self._card_action_toast("此卡片已失效或已处理", toast_type="warning")
|
|
|
|
scheduled = False
|
|
if self._main_loop and self._main_loop.is_running():
|
|
scheduled = bool(
|
|
self._submit_threadsafe_coroutine(
|
|
self._resolve_card_click(entry, option_id=option_id, option_value=option_value, operator_open_id=operator_open_id),
|
|
self._main_loop,
|
|
name="resolve_card_click",
|
|
msg_id=entry.get("card_message_id") or entry["request_id"],
|
|
)
|
|
)
|
|
if not scheduled:
|
|
# Never ack success for a click we could not dispatch: the entry is
|
|
# already consumed, so an honest warning beats a fake "已选择".
|
|
logger.warning("[Feishu] card click could not be dispatched (no running loop or loop closing): request_id=%s", entry["request_id"])
|
|
return self._card_action_toast("处理失败,请改用文字回复", toast_type="warning")
|
|
return self._card_action_toast(f"已选择:{option_value}", toast_type="success")
|
|
|
|
async def _resolve_card_click(self, entry: PendingCardEntry, *, option_id: str, option_value: str, operator_open_id: str) -> None:
|
|
"""Apply a validated click: inject the hidden human-input response first
|
|
(carrying the routing identity the ChannelManager keys on), then flip the
|
|
card to its terminal state only after the answer is admitted to the bus —
|
|
a dropped inbound must never leave a card claiming a choice was recorded."""
|
|
card_message_id = entry.get("card_message_id")
|
|
response = {
|
|
"version": 1,
|
|
"kind": "human_input_response",
|
|
"source": entry["source"],
|
|
"request_id": entry["request_id"],
|
|
"response_kind": "option",
|
|
"option_id": option_id,
|
|
"value": option_value,
|
|
}
|
|
inbound = self._make_inbound(
|
|
chat_id=entry["chat_id"],
|
|
user_id=operator_open_id,
|
|
text=option_value,
|
|
thread_ts=card_message_id,
|
|
metadata={
|
|
"message_id": card_message_id,
|
|
"user_id": operator_open_id,
|
|
"topic_id": entry.get("topic_id"),
|
|
"hide_from_ui": True,
|
|
HUMAN_INPUT_RESPONSE_METADATA_KEY: response,
|
|
},
|
|
)
|
|
inbound.topic_id = entry.get("topic_id")
|
|
inbound.connection_id = entry.get("connection_id")
|
|
inbound.owner_user_id = entry.get("owner_user_id")
|
|
# Bound-identity admission re-reads the connection with the message's
|
|
# workspace_id, and Feishu connections are bound with workspace_id =
|
|
# chat_id (see _bind_connection_from_connect_code). Without this the
|
|
# re-read misses and every click is rejected as unbound.
|
|
inbound.workspace_id = entry.get("chat_id")
|
|
|
|
published = await self._publish_inbound_or_drop(inbound)
|
|
if not published:
|
|
logger.error("[Feishu] card answer dropped by intake queue; card left clickable but the entry is consumed (request_id=%s)", entry["request_id"])
|
|
return
|
|
|
|
self._forget_pending_card_clarification(entry)
|
|
if card_message_id:
|
|
try:
|
|
await self._update_card(card_message_id, f"✅ 已选择:{option_value}")
|
|
except Exception:
|
|
logger.exception("[Feishu] failed to mark card resolved: %s", card_message_id)
|
|
|
|
def _forget_pending_card_clarification(self, entry: PendingCardEntry) -> None:
|
|
"""Retire only this accepted card's plain-text continuation hint."""
|
|
key = self._pending_key(entry["chat_id"], entry["originator_open_id"])
|
|
with self._thread_lock:
|
|
pending_items = self._pending_clarifications.get(key, [])
|
|
remaining = [pending for pending in pending_items if not (pending["card_message_id"] == entry.get("card_message_id") and pending["thread_id"] == entry["thread_id"])]
|
|
if remaining:
|
|
self._pending_clarifications[key] = remaining
|
|
else:
|
|
self._pending_clarifications.pop(key, None)
|
|
|
|
def _remember_pending_clarification(self, msg: OutboundMessage, card_message_id: str | None, *, topic_id: str | None = None) -> None:
|
|
if not msg.is_final or msg.metadata.get(PENDING_CLARIFICATION_METADATA_KEY) is not True:
|
|
return
|
|
|
|
user_id = self._non_empty_str(msg.metadata.get("user_id"))
|
|
topic_id = topic_id or self._non_empty_str(msg.metadata.get("topic_id"))
|
|
source_message_id = self._non_empty_str(msg.thread_ts) or self._non_empty_str(msg.metadata.get("message_id"))
|
|
if not (user_id and topic_id and msg.thread_id and source_message_id and card_message_id):
|
|
return
|
|
|
|
key = self._pending_key(msg.chat_id, user_id)
|
|
pending = {
|
|
"thread_id": msg.thread_id,
|
|
"topic_id": topic_id,
|
|
"source_message_id": source_message_id,
|
|
"card_message_id": card_message_id,
|
|
"created_at": time.time(),
|
|
}
|
|
with self._thread_lock:
|
|
# Plain-message clarification continuity is a short-lived in-memory
|
|
# hint; explicit Feishu replies are still covered by persisted
|
|
# message-id mappings.
|
|
self._pending_clarifications.setdefault(key, []).append(pending)
|
|
logger.info(
|
|
"[Feishu] pending clarification remembered: chat_id=%s user_id=%s topic_id=%s thread_id=%s",
|
|
msg.chat_id,
|
|
user_id,
|
|
topic_id,
|
|
msg.thread_id,
|
|
)
|
|
|
|
def _consume_pending_clarification(self, chat_id: str, user_id: str, *, reply_to_ids: list[str] | None = None) -> dict[str, Any] | None:
|
|
key = self._pending_key(chat_id, user_id)
|
|
with self._thread_lock:
|
|
pending_items = self._pending_clarifications.get(key)
|
|
if not pending_items:
|
|
return None
|
|
|
|
now = time.time()
|
|
pending_items = [pending for pending in pending_items if isinstance(pending.get("created_at"), (int, float)) and now - pending["created_at"] <= PENDING_CLARIFICATION_TTL_SECONDS]
|
|
# Explicit replies target a card/source, while accepted ordinary
|
|
# replies use their current thread alias. Shared roots cannot select
|
|
# a card belonging to a conversation replaced by /new.
|
|
if reply_to_ids is None:
|
|
matched = 0 if pending_items else None
|
|
else:
|
|
matched = next(
|
|
(index for field in ("card_message_id", "source_message_id", "topic_id") for candidate in reply_to_ids for index, pending in enumerate(pending_items) if pending.get(field) == candidate),
|
|
None,
|
|
)
|
|
pending = pending_items.pop(matched) if matched is not None else None
|
|
if pending_items:
|
|
self._pending_clarifications[key] = pending_items
|
|
else:
|
|
self._pending_clarifications.pop(key, None)
|
|
return pending
|
|
|
|
def _route_without_store(self, parsed: _ParsedMessage) -> _Routing:
|
|
"""Routing for a channel constructed without a ``channel_store`` (no lookups possible)."""
|
|
topic_id: str | None = parsed.root_id or parsed.msg_id
|
|
if parsed.chat_type == "p2p":
|
|
topic_id = None
|
|
if parsed.msg_type != InboundMessageType.CHAT:
|
|
return _Routing(topic_id, False, False)
|
|
if self._card_interaction_enabled and parsed.chat_type == "p2p" and not (parsed.root_id or parsed.parent_id or parsed.feishu_thread_id):
|
|
return _Routing(None, False, False)
|
|
reply_to_ids = [parsed.parent_id] if self._card_interaction_enabled and parsed.parent_id else None
|
|
pending = self._consume_pending_clarification(parsed.chat_id, parsed.sender_id, reply_to_ids=reply_to_ids)
|
|
pending_topic_id = self._non_empty_str(pending.get("topic_id")) if pending else None
|
|
if not pending_topic_id:
|
|
return _Routing(topic_id, False, False)
|
|
return _Routing(pending_topic_id, False, True, self._non_empty_str(pending.get("card_message_id")))
|
|
|
|
async def _resolve_routing(self, attempt: _RoutingAttempt) -> _Routing:
|
|
"""Decide a message's topic on the Gateway loop, consulting and updating the channel store.
|
|
|
|
Stored mappings for the root / parent / thread ids win (``_first_stored_topic``);
|
|
otherwise the route is the root (or the message itself; ``None`` in P2P chats),
|
|
unless a pending clarification for the sender names a topic — that topic's
|
|
mapping is then persisted so the manager finds the clarified thread. The
|
|
pending clarification is consumed once: a retried attempt keeps the decision
|
|
and only redoes the write.
|
|
"""
|
|
store = self.config.get("channel_store")
|
|
parsed = attempt.parsed
|
|
if attempt.pending is None:
|
|
candidates = [candidate for candidate in (self._non_empty_str(parsed.root_id), parsed.parent_id, parsed.feishu_thread_id) if candidate]
|
|
if candidates:
|
|
stored = await self._first_stored_topic(store, parsed.chat_id, candidates)
|
|
if stored:
|
|
pending = (
|
|
self._consume_pending_clarification(parsed.chat_id, parsed.sender_id, reply_to_ids=[parsed.parent_id]) if self._card_interaction_enabled and parsed.msg_type == InboundMessageType.CHAT and parsed.parent_id else None
|
|
)
|
|
if pending:
|
|
return _Routing(pending["topic_id"], True, True, self._non_empty_str(pending.get("card_message_id")))
|
|
return _Routing(stored, True, False)
|
|
topic_id: str | None = parsed.root_id or parsed.msg_id
|
|
if parsed.chat_type == "p2p":
|
|
topic_id = None
|
|
if parsed.msg_type != InboundMessageType.CHAT:
|
|
return _Routing(topic_id, False, False)
|
|
if self._card_interaction_enabled and parsed.chat_type == "p2p" and not candidates:
|
|
# Unthreaded P2P messages follow the current conversation,
|
|
# including /new. Only explicit card replies use the old alias.
|
|
return _Routing(None, False, False)
|
|
reply_to_ids = [parsed.parent_id] if self._card_interaction_enabled and parsed.parent_id else None
|
|
pending = self._consume_pending_clarification(parsed.chat_id, parsed.sender_id, reply_to_ids=reply_to_ids)
|
|
if not (pending and self._non_empty_str(pending.get("topic_id"))):
|
|
return _Routing(topic_id, False, False)
|
|
attempt.pending = pending
|
|
pending_topic_id = self._non_empty_str(attempt.pending.get("topic_id"))
|
|
thread_id = self._non_empty_str(attempt.pending.get("thread_id"))
|
|
if thread_id:
|
|
await store.set_thread_id(self.name, parsed.chat_id, thread_id, topic_id=pending_topic_id, user_id=parsed.sender_id)
|
|
return _Routing(pending_topic_id, False, True, self._non_empty_str(attempt.pending.get("card_message_id")))
|
|
|
|
async def _resolve_routing_bounded(self, attempt: _RoutingAttempt) -> _Routing | StoreLookupUnresolved:
|
|
"""``_resolve_routing`` under the store deadline; no answer in time (or a store error) is *unresolved*.
|
|
|
|
The unresolved outcome is *returned*, not raised: this coroutine runs as a
|
|
tracked submission whose failure the base class reports at ERROR, and an
|
|
unanswered lookup during a store outage is an expected, handled outcome.
|
|
"""
|
|
attempt.attempted = True
|
|
try:
|
|
return await asyncio.wait_for(self._resolve_routing(attempt), FEISHU_STORE_LOOKUP_TIMEOUT_SECONDS)
|
|
except TimeoutError:
|
|
return StoreLookupUnresolved(f"channel store did not answer within {FEISHU_STORE_LOOKUP_TIMEOUT_SECONDS:g}s")
|
|
except Exception as exc:
|
|
# A failing store is not "no mapping": routing on it would open a new
|
|
# conversation for a reply whose thread the database still knows.
|
|
logger.warning("[Feishu] channel store call failed for message %s (%s); treating the lookup as unresolved", attempt.parsed.msg_id, exc.__class__.__name__, exc_info=True)
|
|
return StoreLookupUnresolved(f"channel store error: {exc.__class__.__name__}")
|
|
|
|
async def _first_stored_topic(self, store: Any, chat_id: str, candidates: list[str]) -> str | None:
|
|
"""The first candidate topic id the store already maps to a thread, in priority order."""
|
|
for candidate in candidates:
|
|
if await store.get_thread_id(self.name, chat_id, topic_id=candidate):
|
|
return candidate
|
|
return None
|
|
|
|
def _defer_routing(self, attempt: _RoutingAttempt, unresolved: StoreLookupUnresolved) -> None:
|
|
"""Hand an unresolved message to the Gateway loop (lark thread; never blocks).
|
|
|
|
The chat gets a FIFO deque and one drain task; later messages for the same
|
|
chat queue behind it in ``_on_message`` so none overtakes a deferred
|
|
predecessor onto a different thread. Without a running Gateway loop the
|
|
message is dropped (logged) — nothing could dispatch it anyway.
|
|
"""
|
|
parsed = attempt.parsed
|
|
loop = self._main_loop
|
|
if loop is None or not loop.is_running():
|
|
attempt.release_reservation()
|
|
logger.warning("[Feishu] dropping message %s for chat %s: %s and no Gateway loop to defer it to", parsed.msg_id, parsed.chat_id, unresolved.reason)
|
|
return
|
|
queue: deque[_RoutingAttempt] = deque([attempt])
|
|
with self._thread_lock:
|
|
existing = self._deferred_routing.get(parsed.chat_id)
|
|
if existing is not None:
|
|
existing.append(attempt)
|
|
return
|
|
self._deferred_routing[parsed.chat_id] = queue
|
|
scheduled = self._submit_threadsafe_coroutine(
|
|
self._drain_deferred_routing(parsed.chat_id, queue, unresolved.future),
|
|
loop,
|
|
name="deferred_routing",
|
|
msg_id=parsed.msg_id,
|
|
)
|
|
if not scheduled:
|
|
with self._thread_lock:
|
|
if self._deferred_routing.get(parsed.chat_id) is queue:
|
|
del self._deferred_routing[parsed.chat_id]
|
|
attempt.release_reservation()
|
|
logger.warning("[Feishu] dropping message %s for chat %s: %s and the channel is stopping", parsed.msg_id, parsed.chat_id, unresolved.reason)
|
|
return
|
|
logger.info("[Feishu] deferred message %s for chat %s: %s; retrying on the Gateway loop for up to %.0fs", parsed.msg_id, parsed.chat_id, unresolved.reason, FEISHU_STORE_RESOLVE_TOTAL_BUDGET_SECONDS)
|
|
|
|
async def _drain_deferred_routing(self, chat_id: str, queue: deque[_RoutingAttempt], first_future: Future[Any] | None) -> None:
|
|
"""Resolve and dispatch a chat's deferred messages in order, then retire the queue."""
|
|
try:
|
|
while True:
|
|
with self._thread_lock:
|
|
if not queue:
|
|
if self._deferred_routing.get(chat_id) is queue:
|
|
del self._deferred_routing[chat_id]
|
|
return
|
|
attempt = queue[0]
|
|
routing: _Routing | None = None
|
|
if first_future is not None:
|
|
# The lark thread's own call may still finish (its deadline is
|
|
# the coroutine's); use that answer before spending retries.
|
|
pending_first, first_future = first_future, None
|
|
try:
|
|
outcome = await asyncio.shield(asyncio.wrap_future(pending_first))
|
|
except StoreLookupUnresolved:
|
|
outcome = None
|
|
routing = outcome if isinstance(outcome, _Routing) else None
|
|
if routing is None:
|
|
routing = await self._retry_routing(attempt)
|
|
try:
|
|
if routing is None:
|
|
attempt.release_reservation()
|
|
logger.warning(
|
|
"[Feishu] dropping message %s for chat %s: the channel store did not answer within %gs of its arrival; not routing it as a new conversation",
|
|
attempt.parsed.msg_id,
|
|
chat_id,
|
|
FEISHU_STORE_RESOLVE_TOTAL_BUDGET_SECONDS,
|
|
)
|
|
else:
|
|
self._dispatch_inbound(attempt.parsed, routing, reservation=attempt.reservation)
|
|
attempt.reservation = None
|
|
finally:
|
|
with self._thread_lock:
|
|
if queue and queue[0] is attempt:
|
|
queue.popleft()
|
|
finally:
|
|
with self._thread_lock:
|
|
leftovers = list(queue)
|
|
queue.clear()
|
|
if self._deferred_routing.get(chat_id) is queue:
|
|
del self._deferred_routing[chat_id]
|
|
for leftover in leftovers:
|
|
leftover.release_reservation()
|
|
if leftovers:
|
|
logger.warning("[Feishu] dropping %d deferred messages for chat %s: the channel is stopping", len(leftovers), chat_id)
|
|
|
|
async def _retry_routing(self, attempt: _RoutingAttempt) -> _Routing | None:
|
|
"""Retry the bounded resolution with backoff until the message's own budget; ``None`` when it never answered.
|
|
|
|
The budget is ``FEISHU_STORE_RESOLVE_TOTAL_BUDGET_SECONDS`` from the message's
|
|
arrival, so a message that waited behind others in its chat's queue does not
|
|
start a fresh budget at the head: one whose budget is already spent is dropped
|
|
without another lookup.
|
|
"""
|
|
deadline = attempt.arrived_at + FEISHU_STORE_RESOLVE_TOTAL_BUDGET_SECONDS
|
|
backoff = FEISHU_STORE_RESOLVE_RETRY_BACKOFF_SECONDS
|
|
while True:
|
|
remaining = deadline - time.monotonic()
|
|
if remaining <= 0:
|
|
return None
|
|
if attempt.attempted:
|
|
await asyncio.sleep(min(backoff, remaining))
|
|
backoff = min(backoff * 2, FEISHU_STORE_RESOLVE_RETRY_MAX_BACKOFF_SECONDS)
|
|
if time.monotonic() >= deadline:
|
|
return None
|
|
outcome = await self._resolve_routing_bounded(attempt)
|
|
if isinstance(outcome, _Routing):
|
|
return outcome
|
|
logger.debug("[Feishu] retrying routing for message %s: %s", attempt.parsed.msg_id, outcome.reason)
|
|
|
|
def _dispatch_inbound(self, parsed: _ParsedMessage, routing: _Routing, *, reservation: InboundReservation | None = None) -> None:
|
|
"""Build the inbound for a routed message and hand it to batching / the Gateway loop.
|
|
|
|
``reservation`` is the intake slot taken before routing; it is handed to
|
|
``_schedule_prepare_inbound`` (committed or released there) or released on the
|
|
batch path (the batch flush reserves when it publishes) and on failure.
|
|
"""
|
|
try:
|
|
source_preview = None
|
|
if self._should_include_source_preview(chat_type=parsed.chat_type, root_id=parsed.root_id, parent_id=parsed.parent_id, thread_id=parsed.feishu_thread_id):
|
|
source_preview = self._compact_source_preview(parsed.text)
|
|
|
|
metadata = {
|
|
"message_id": parsed.msg_id,
|
|
"root_id": parsed.root_id,
|
|
"parent_id": parsed.parent_id,
|
|
"thread_id": parsed.feishu_thread_id,
|
|
"topic_id": routing.topic_id,
|
|
"user_id": parsed.sender_id,
|
|
RESOLVED_FROM_PENDING_CLARIFICATION_METADATA_KEY: routing.from_pending,
|
|
}
|
|
if source_preview:
|
|
metadata[SOURCE_PREVIEW_METADATA_KEY] = source_preview
|
|
if routing.clarification_card_id:
|
|
metadata[_PENDING_CARD_METADATA_KEY] = routing.clarification_card_id
|
|
|
|
inbound = self._make_inbound(
|
|
chat_id=parsed.chat_id,
|
|
user_id=parsed.sender_id,
|
|
text=parsed.text,
|
|
msg_type=parsed.msg_type,
|
|
thread_ts=parsed.msg_id,
|
|
files=parsed.files,
|
|
metadata=metadata,
|
|
)
|
|
inbound.topic_id = routing.topic_id
|
|
|
|
if self._is_batchable_file_inbound(
|
|
msg_type=parsed.msg_type,
|
|
text=parsed.text,
|
|
files=parsed.files,
|
|
root_id=parsed.root_id,
|
|
parent_id=parsed.parent_id,
|
|
thread_id=parsed.feishu_thread_id,
|
|
):
|
|
if reservation is not None:
|
|
reservation.release()
|
|
self._queue_file_inbound_batch(parsed.msg_id, inbound)
|
|
return
|
|
|
|
self._schedule_prepare_inbound(parsed.msg_id, inbound, reservation=reservation)
|
|
except Exception:
|
|
if reservation is not None:
|
|
reservation.release()
|
|
logger.exception("[Feishu] error dispatching message %s", parsed.msg_id)
|
|
|
|
@staticmethod
|
|
def _is_batchable_file_inbound(
|
|
*,
|
|
msg_type: InboundMessageType,
|
|
text: str,
|
|
files: list[dict[str, Any]],
|
|
root_id: str | None,
|
|
parent_id: str | None,
|
|
thread_id: str | None,
|
|
) -> bool:
|
|
return msg_type == InboundMessageType.CHAT and text in {"[file]", "[image]"} and len(files) == 1 and not (root_id or parent_id or thread_id)
|
|
|
|
def _schedule_prepare_inbound(
|
|
self,
|
|
msg_id: str,
|
|
inbound: InboundMessage,
|
|
*,
|
|
source_message_ids: list[str] | None = None,
|
|
reservation: InboundReservation | None = None,
|
|
) -> None:
|
|
if self._main_loop and self._main_loop.is_running():
|
|
if reservation is None:
|
|
reservation = self._reserve_inbound(inbound)
|
|
if reservation is None:
|
|
return
|
|
logger.info("[Feishu] publishing inbound message to bus (type=%s, msg_id=%s)", inbound.msg_type.value, msg_id)
|
|
scheduled = self._submit_threadsafe_coroutine(
|
|
self._prepare_inbound(
|
|
msg_id,
|
|
inbound,
|
|
source_message_ids=source_message_ids,
|
|
reservation=reservation,
|
|
),
|
|
self._main_loop,
|
|
name="prepare_inbound",
|
|
msg_id=msg_id,
|
|
reservation=reservation,
|
|
)
|
|
if not scheduled:
|
|
logger.info("[Feishu] main loop stopped before reserved inbound could be scheduled")
|
|
else:
|
|
if reservation is not None:
|
|
reservation.release()
|
|
logger.warning("[Feishu] main loop not running, cannot publish inbound message")
|
|
|
|
def _schedule_batch_flush(self, key: tuple[str, str], source_message_id: str) -> None:
|
|
if self._main_loop and self._main_loop.is_running():
|
|
scheduled = self._submit_threadsafe_coroutine(
|
|
self._flush_pending_inbound_batch_after(key, source_message_id),
|
|
self._main_loop,
|
|
name="flush_inbound_batch",
|
|
msg_id=source_message_id,
|
|
)
|
|
if not scheduled:
|
|
logger.info("[Feishu] main loop stopped before inbound batch flush could be scheduled")
|
|
else:
|
|
logger.warning("[Feishu] main loop not running, cannot flush inbound batch")
|
|
|
|
def _queue_file_inbound_batch(self, msg_id: str, inbound: InboundMessage) -> bool:
|
|
key = self._pending_key(inbound.chat_id, inbound.user_id)
|
|
should_schedule_flush = False
|
|
expired_batch: tuple[str, InboundMessage, list[str]] | None = None
|
|
|
|
with self._thread_lock:
|
|
batch = self._pending_inbound_batches.get(key)
|
|
now = time.time()
|
|
if batch:
|
|
if now - batch["created_at"] <= FEISHU_INBOUND_BATCH_WINDOW_SECONDS:
|
|
batched_inbound = batch["inbound"]
|
|
batch["message_ids"].append(msg_id)
|
|
batch["text_parts"].append(inbound.text)
|
|
batched_inbound.text = "\n\n".join(part for part in batch["text_parts"] if part)
|
|
batched_inbound.files.extend(inbound.files)
|
|
batched_inbound.metadata["batched_message_ids"] = list(batch["message_ids"])
|
|
logger.info(
|
|
"[Feishu] batched inbound file message: chat_id=%s user_id=%s anchor=%s msg_id=%s files=%d",
|
|
inbound.chat_id,
|
|
inbound.user_id,
|
|
batch["anchor_message_id"],
|
|
msg_id,
|
|
len(batched_inbound.files),
|
|
)
|
|
return True
|
|
|
|
expired_batch = (batch["anchor_message_id"], batch["inbound"], list(batch["message_ids"]))
|
|
|
|
self._pending_inbound_batches[key] = {
|
|
"anchor_message_id": msg_id,
|
|
"created_at": now,
|
|
"inbound": inbound,
|
|
"message_ids": [msg_id],
|
|
"text_parts": [inbound.text],
|
|
}
|
|
inbound.metadata["batched_message_ids"] = [msg_id]
|
|
should_schedule_flush = True
|
|
|
|
if should_schedule_flush:
|
|
self._schedule_batch_flush(key, msg_id)
|
|
if expired_batch:
|
|
anchor_message_id, expired_inbound, source_message_ids = expired_batch
|
|
self._schedule_prepare_inbound(anchor_message_id, expired_inbound, source_message_ids=source_message_ids)
|
|
return True
|
|
|
|
def _pop_pending_inbound_batch(self, key: tuple[str, str], *, anchor_message_id: str | None = None) -> tuple[str, InboundMessage, list[str]] | None:
|
|
with self._thread_lock:
|
|
batch = self._pending_inbound_batches.get(key)
|
|
if not batch:
|
|
return None
|
|
if anchor_message_id is not None and batch["anchor_message_id"] != anchor_message_id:
|
|
return None
|
|
self._pending_inbound_batches.pop(key, None)
|
|
return batch["anchor_message_id"], batch["inbound"], list(batch["message_ids"])
|
|
|
|
async def _flush_pending_inbound_batch_after(self, key: tuple[str, str], anchor_message_id: str) -> None:
|
|
await asyncio.sleep(FEISHU_INBOUND_BATCH_WINDOW_SECONDS)
|
|
batch = self._pop_pending_inbound_batch(key, anchor_message_id=anchor_message_id)
|
|
if not batch:
|
|
return
|
|
anchor_message_id, inbound, source_message_ids = batch
|
|
reservation = self._reserve_inbound(inbound)
|
|
if reservation is None:
|
|
return
|
|
logger.info(
|
|
"[Feishu] flushing inbound file batch: chat_id=%s user_id=%s anchor=%s messages=%d files=%d",
|
|
inbound.chat_id,
|
|
inbound.user_id,
|
|
anchor_message_id,
|
|
len(source_message_ids),
|
|
len(inbound.files),
|
|
)
|
|
await self._prepare_inbound(
|
|
anchor_message_id,
|
|
inbound,
|
|
source_message_ids=source_message_ids,
|
|
reservation=reservation,
|
|
)
|
|
|
|
@staticmethod
|
|
def _log_task_error(task: asyncio.Task, name: str, msg_id: str) -> None:
|
|
"""Callback for background asyncio tasks to surface errors."""
|
|
try:
|
|
exc = task.exception()
|
|
if exc:
|
|
logger.error("[Feishu] %s failed for msg_id=%s: %s", name, msg_id, exc)
|
|
except asyncio.CancelledError:
|
|
logger.info("[Feishu] %s cancelled for msg_id=%s", name, msg_id)
|
|
except Exception:
|
|
pass
|
|
|
|
async def _prepare_inbound(
|
|
self,
|
|
msg_id: str,
|
|
inbound,
|
|
*,
|
|
source_message_ids: list[str] | None = None,
|
|
reservation: InboundReservation | None = None,
|
|
) -> None:
|
|
"""Kick off Feishu side effects without delaying inbound dispatch."""
|
|
try:
|
|
clarification_card_id = inbound.metadata.pop(_PENDING_CARD_METADATA_KEY, None)
|
|
inbound = await self._attach_connection_identity(inbound)
|
|
if self._card_interaction_enabled and not clarification_card_id and inbound.msg_type == InboundMessageType.CHAT:
|
|
# Ordinary answers retire only the current thread's buttons;
|
|
# older cards cannot redirect a fresh /new conversation.
|
|
thread_id = await lookup_thread_id(inbound, repo=self._connection_repo, store=self.config.get("channel_store"))
|
|
pending = self._consume_pending_clarification(inbound.chat_id, inbound.user_id, reply_to_ids=[f"feishu-card:{thread_id}"]) if thread_id else None
|
|
if pending:
|
|
clarification_card_id = pending.get("card_message_id")
|
|
inbound.metadata[RESOLVED_FROM_PENDING_CLARIFICATION_METADATA_KEY] = True
|
|
reaction_message_ids = source_message_ids or [msg_id]
|
|
for reaction_message_id in reaction_message_ids:
|
|
reaction_task = asyncio.create_task(self._add_reaction(reaction_message_id, "OK"))
|
|
self._track_background_task(reaction_task, name="add_reaction", msg_id=reaction_message_id)
|
|
self._ensure_running_card_started(msg_id, metadata=inbound.metadata)
|
|
if reservation is None:
|
|
await self.bus.publish_inbound(inbound)
|
|
published = True
|
|
else:
|
|
published = self._commit_reserved_inbound(reservation, inbound)
|
|
if published and clarification_card_id:
|
|
self._card_registry.discard_card(inbound.chat_id, inbound.user_id, clarification_card_id)
|
|
finally:
|
|
if reservation is not None:
|
|
reservation.release()
|
|
|
|
async def _attach_connection_identity(self, inbound: InboundMessage) -> InboundMessage:
|
|
return await attach_connection_identity(
|
|
inbound,
|
|
repo=self._connection_repo,
|
|
provider="feishu",
|
|
workspace_id=inbound.chat_id,
|
|
)
|
|
|
|
async def _get_group_name(self, chat_id: str) -> str | None:
|
|
"""Best-effort display name; missing read permission must not break binding."""
|
|
if not self._api_client or not self._GetChatRequest:
|
|
return None
|
|
try:
|
|
request = self._GetChatRequest.builder().chat_id(chat_id).build()
|
|
response = await asyncio.to_thread(self._api_client.im.v1.chat.get, request)
|
|
if response.success():
|
|
return self._non_empty_str(getattr(response.data, "name", None))
|
|
except Exception:
|
|
logger.debug("[Feishu] group name lookup unavailable; binding without a display name")
|
|
return None
|
|
|
|
async def _bind_connection_from_connect_code(self, *, message_id: str, chat_id: str, user_id: str, code: str, chat_type: str | None = None) -> bool:
|
|
if self._connection_repo is None or not code:
|
|
return False
|
|
|
|
state = await self._connection_repo.consume_oauth_state(provider="feishu", state=code)
|
|
if state is None:
|
|
await self._reply_card(message_id, "Feishu connection code is invalid or expired.")
|
|
return True
|
|
|
|
if not user_id or not chat_id:
|
|
await self._reply_card(message_id, "Feishu connection could not be completed from this message.")
|
|
return True
|
|
|
|
await self._connection_repo.upsert_connection(
|
|
owner_user_id=state["owner_user_id"],
|
|
provider="feishu",
|
|
external_account_id=user_id,
|
|
workspace_id=chat_id,
|
|
workspace_name=(notification_group_display_name(state.get("metadata")) or await self._get_group_name(chat_id)) if chat_type == "group" else None,
|
|
metadata={
|
|
"chat_id": chat_id,
|
|
"message_id": message_id,
|
|
"chat_type": chat_type,
|
|
},
|
|
status="connected",
|
|
)
|
|
await self._reply_card(message_id, "Feishu connected to DeerFlow.")
|
|
return True
|
|
|
|
def _parse_message_event(self, event) -> _ParsedMessage | None:
|
|
"""Extract text, attachments and ids from a lark message event; ``None`` for an empty message."""
|
|
message = event.event.message
|
|
chat_id = message.chat_id
|
|
msg_id = message.message_id
|
|
sender_id = event.event.sender.sender_id.open_id
|
|
|
|
root_id = getattr(message, "root_id", None) or None
|
|
chat_type = getattr(message, "chat_type", None)
|
|
parent_id = self._non_empty_str(getattr(message, "parent_id", None))
|
|
feishu_thread_id = self._non_empty_str(getattr(message, "thread_id", None))
|
|
|
|
# Parse message content
|
|
content = json.loads(message.content)
|
|
|
|
# files_list store the any-file-key in feishu messages, which can be used to download the file content later
|
|
# In Feishu channel, image_keys are independent of file_keys.
|
|
# The file_key includes files, videos, and audio, but does not include stickers.
|
|
files_list = []
|
|
|
|
if "text" in content:
|
|
# Handle plain text messages
|
|
text = content["text"]
|
|
elif "file_key" in content:
|
|
file_key = content.get("file_key")
|
|
if isinstance(file_key, str) and file_key:
|
|
files_list.append({"file_key": file_key})
|
|
text = "[file]"
|
|
else:
|
|
text = ""
|
|
elif "image_key" in content:
|
|
image_key = content.get("image_key")
|
|
if isinstance(image_key, str) and image_key:
|
|
files_list.append({"image_key": image_key})
|
|
text = "[image]"
|
|
else:
|
|
text = ""
|
|
elif "content" in content and isinstance(content["content"], list):
|
|
# Handle rich-text messages with a top-level "content" list (e.g., topic groups/posts)
|
|
text_paragraphs: list[str] = []
|
|
for paragraph in content["content"]:
|
|
if isinstance(paragraph, list):
|
|
paragraph_text_parts: list[str] = []
|
|
for element in paragraph:
|
|
if isinstance(element, dict):
|
|
# Include both normal text and @ mentions
|
|
if element.get("tag") in ("text", "at"):
|
|
text_value = element.get("text", "")
|
|
if text_value:
|
|
paragraph_text_parts.append(text_value)
|
|
elif element.get("tag") == "img":
|
|
image_key = element.get("image_key")
|
|
if isinstance(image_key, str) and image_key:
|
|
files_list.append({"image_key": image_key})
|
|
paragraph_text_parts.append("[image]")
|
|
elif element.get("tag") in ("file", "media"):
|
|
file_key = element.get("file_key")
|
|
if isinstance(file_key, str) and file_key:
|
|
files_list.append({"file_key": file_key})
|
|
paragraph_text_parts.append("[file]")
|
|
if paragraph_text_parts:
|
|
# Join text segments within a paragraph with spaces to avoid "helloworld"
|
|
text_paragraphs.append(" ".join(paragraph_text_parts))
|
|
|
|
# Join paragraphs with blank lines to preserve paragraph boundaries
|
|
text = "\n\n".join(text_paragraphs)
|
|
else:
|
|
text = ""
|
|
text = text.strip()
|
|
|
|
logger.info(
|
|
"[Feishu] parsed message: chat_id=%s, msg_id=%s, root_id=%s, parent_id=%s, thread_id=%s, chat_type=%s, sender=%s, text_len=%d",
|
|
chat_id,
|
|
msg_id,
|
|
root_id,
|
|
parent_id,
|
|
feishu_thread_id,
|
|
chat_type,
|
|
sender_id,
|
|
len(text or ""),
|
|
)
|
|
|
|
if not (text or files_list):
|
|
logger.info("[Feishu] empty text, ignoring message")
|
|
return None
|
|
return _ParsedMessage(
|
|
chat_id=chat_id,
|
|
msg_id=msg_id,
|
|
sender_id=sender_id,
|
|
root_id=root_id,
|
|
parent_id=parent_id,
|
|
feishu_thread_id=feishu_thread_id,
|
|
chat_type=chat_type,
|
|
text=text,
|
|
files=files_list,
|
|
)
|
|
|
|
def _on_message(self, event) -> None:
|
|
"""Called by lark-oapi when a message is received (runs synchronously on the SDK's loop thread)."""
|
|
try:
|
|
parsed = self._parse_message_event(event)
|
|
if parsed is None:
|
|
return
|
|
msg_id, chat_id, sender_id, text = parsed.msg_id, parsed.chat_id, parsed.sender_id, parsed.text
|
|
|
|
connect_code = self._pending_connect_code(text)
|
|
if connect_code:
|
|
if self._main_loop and self._main_loop.is_running():
|
|
scheduled = self._submit_threadsafe_coroutine(
|
|
self._bind_connection_from_connect_code(
|
|
message_id=msg_id,
|
|
chat_id=chat_id,
|
|
user_id=sender_id,
|
|
code=connect_code,
|
|
chat_type=parsed.chat_type,
|
|
),
|
|
self._main_loop,
|
|
name="bind_connection",
|
|
msg_id=msg_id,
|
|
)
|
|
if not scheduled:
|
|
logger.info("[Feishu] main loop stopped before channel connection bind could be scheduled")
|
|
else:
|
|
logger.warning("[Feishu] main loop not running, cannot bind channel connection")
|
|
return
|
|
|
|
# Only treat known slash commands as commands; absolute paths and
|
|
# other slash-prefixed text should be handled as normal chat.
|
|
# Feishu group chats deliver "@bot /goal" with the mention left in the
|
|
# text (Slack/Discord strip their own bot mention upstream). Skip a
|
|
# leading mention only for the command path so ordinary chat keeps any
|
|
# @mentions intact for the agent; the stripped form also flows into the
|
|
# inbound so ChannelManager._handle_command parses the bare command.
|
|
command_text = strip_leading_mentions(text)
|
|
if _is_feishu_command(command_text):
|
|
parsed.msg_type = InboundMessageType.COMMAND
|
|
parsed.text = command_text
|
|
|
|
# topic_id determines which LangGraph thread the message maps to.
|
|
# P2P chats: topic_id=None so all messages share one thread (like Telegram DMs).
|
|
# But check stored mappings first for backward compatibility with pre-upgrade P2P threads.
|
|
if self.config.get("channel_store") is None:
|
|
self._dispatch_inbound(parsed, self._route_without_store(parsed))
|
|
return
|
|
|
|
# Reserve bounded intake *before* routing: a message that may be deferred
|
|
# while the store is slow holds a real slot of the inbound queue, so the
|
|
# deferred backlog shares ``inbound_queue_maxsize`` with live messages.
|
|
# The placeholder only feeds the bus's capacity warning.
|
|
placeholder = self._make_inbound(chat_id=chat_id, user_id=sender_id, text=parsed.text, msg_type=parsed.msg_type, thread_ts=msg_id, files=parsed.files)
|
|
reservation = self._reserve_inbound(placeholder)
|
|
if reservation is None:
|
|
logger.warning("[Feishu] dropping message %s for chat %s: inbound intake capacity exhausted", msg_id, chat_id)
|
|
return
|
|
attempt = _RoutingAttempt(parsed, reservation=reservation)
|
|
with self._thread_lock:
|
|
queue = self._deferred_routing.get(chat_id)
|
|
if queue is not None:
|
|
# Per-chat FIFO: never overtake a deferred predecessor onto another thread.
|
|
queue.append(attempt)
|
|
logger.info("[Feishu] queued message %s behind a deferred message for chat %s", msg_id, chat_id)
|
|
return
|
|
try:
|
|
outcome = self._run_store_call(lambda: self._resolve_routing_bounded(attempt), name="resolve_topic_mapping", msg_id=msg_id)
|
|
except StoreLookupUnresolved as unresolved:
|
|
outcome = unresolved
|
|
if isinstance(outcome, StoreLookupUnresolved):
|
|
self._defer_routing(attempt, outcome)
|
|
return
|
|
self._dispatch_inbound(parsed, outcome, reservation=attempt.reservation)
|
|
except Exception:
|
|
logger.exception("[Feishu] error processing message")
|