|
|
@@ -0,0 +1,220 @@
|
|
|
+from __future__ import annotations
|
|
|
+
|
|
|
+import logging
|
|
|
+from collections.abc import Mapping
|
|
|
+from typing import Any, Protocol, runtime_checkable
|
|
|
+
|
|
|
+from gateway.core.channels.feishu.connector import FeishuConnector
|
|
|
+from gateway.core.channels.feishu.types import (
|
|
|
+ FeishuReplyContext,
|
|
|
+ IncomingFeishuEvent,
|
|
|
+ feishu_event_to_mapping,
|
|
|
+)
|
|
|
+from gateway.core.channels.manager import TraceBackend
|
|
|
+from gateway.core.channels.router import ChannelTraceRouter
|
|
|
+from gateway.core.channels.types import CHANNEL_FEISHU, RouteResult
|
|
|
+
|
|
|
+logger = logging.getLogger(__name__)
|
|
|
+
|
|
|
+
|
|
|
+@runtime_checkable
|
|
|
+class FeishuExecutorBackend(Protocol):
|
|
|
+ """与 Executor 对接前的抽象:处理入站用户文本并负责回呼飞书。"""
|
|
|
+
|
|
|
+ async def handle_inbound_message(
|
|
|
+ self,
|
|
|
+ trace_id: str,
|
|
|
+ text: str,
|
|
|
+ reply_context: FeishuReplyContext,
|
|
|
+ connector: Any,
|
|
|
+ *,
|
|
|
+ event: IncomingFeishuEvent,
|
|
|
+ ) -> str:
|
|
|
+ """返回 task_id 或占位 id。"""
|
|
|
+ ...
|
|
|
+
|
|
|
+
|
|
|
+@runtime_checkable
|
|
|
+class FeishuUserIdentityResolver(Protocol):
|
|
|
+ """将飞书事件映射为网关内统一 user_id(后续可换 DB 映射表)。"""
|
|
|
+
|
|
|
+ def resolve_user_id(self, event: IncomingFeishuEvent) -> str:
|
|
|
+ ...
|
|
|
+
|
|
|
+
|
|
|
+class FeishuMessageRouter(ChannelTraceRouter):
|
|
|
+ """
|
|
|
+ 飞书消息路由:用户 → trace_id → Executor;与 channels.md 中 MessageRouter 一致。
|
|
|
+
|
|
|
+ 非 message 事件(reaction / card_action)默认跳过执行器,仅返回 200。
|
|
|
+ """
|
|
|
+
|
|
|
+ def __init__(
|
|
|
+ self,
|
|
|
+ *,
|
|
|
+ connector: FeishuConnector,
|
|
|
+ trace_backend: TraceBackend,
|
|
|
+ executor_backend: FeishuExecutorBackend,
|
|
|
+ identity_resolver: FeishuUserIdentityResolver,
|
|
|
+ workspace_prefix: str = CHANNEL_FEISHU,
|
|
|
+ default_agent_type: str = "personal_assistant",
|
|
|
+ auto_create_trace: bool = True,
|
|
|
+ dispatch_reactions: bool = False,
|
|
|
+ dispatch_card_actions: bool = False,
|
|
|
+ ) -> None:
|
|
|
+ super().__init__(
|
|
|
+ trace_backend=trace_backend,
|
|
|
+ workspace_prefix=workspace_prefix,
|
|
|
+ default_agent_type=default_agent_type,
|
|
|
+ )
|
|
|
+ self._connector = connector
|
|
|
+ self._executor = executor_backend
|
|
|
+ self._identity = identity_resolver
|
|
|
+ self._auto_create = auto_create_trace
|
|
|
+ self._dispatch_reactions = dispatch_reactions
|
|
|
+ self._dispatch_card_actions = dispatch_card_actions
|
|
|
+
|
|
|
+ def _reply_context_from_event(self, event: IncomingFeishuEvent) -> FeishuReplyContext | None:
|
|
|
+ if not event.chat_id:
|
|
|
+ logger.warning("missing chat_id, cannot reply: %s", feishu_event_to_mapping(event))
|
|
|
+ return None
|
|
|
+ return FeishuReplyContext(
|
|
|
+ account_id=event.account_id,
|
|
|
+ app_id=event.app_id,
|
|
|
+ chat_id=event.chat_id,
|
|
|
+ message_id=event.message_id,
|
|
|
+ open_id=event.open_id,
|
|
|
+ )
|
|
|
+
|
|
|
+ def _synthetic_text_for_event(self, event: IncomingFeishuEvent) -> str | None:
|
|
|
+ if event.event_type == "reaction" and event.emoji:
|
|
|
+ return f"[系统-表情] {event.emoji} message_id={event.message_id or ''}"
|
|
|
+ if event.event_type == "card_action":
|
|
|
+ return (
|
|
|
+ f"[系统-卡片] action={event.action or ''} "
|
|
|
+ f"operation_id={event.operation_id or ''}"
|
|
|
+ )
|
|
|
+ return None
|
|
|
+
|
|
|
+ async def route_feishu_inbound_event(self, event: IncomingFeishuEvent) -> RouteResult:
|
|
|
+ """处理 Feishu HTTP 适配服务转发的规范化入站事件。"""
|
|
|
+ user_id = self._identity.resolve_user_id(event)
|
|
|
+ workspace_id = self._workspace_id_for_user(user_id)
|
|
|
+
|
|
|
+ dispatch = False
|
|
|
+ text: str | None = None
|
|
|
+ if event.event_type == "message":
|
|
|
+ dispatch = True
|
|
|
+ text = event.content or ""
|
|
|
+ elif event.event_type == "reaction" and self._dispatch_reactions:
|
|
|
+ dispatch = True
|
|
|
+ text = self._synthetic_text_for_event(event) or ""
|
|
|
+ elif event.event_type == "card_action" and self._dispatch_card_actions:
|
|
|
+ dispatch = True
|
|
|
+ text = self._synthetic_text_for_event(event) or ""
|
|
|
+
|
|
|
+ if not dispatch:
|
|
|
+ return RouteResult(
|
|
|
+ ok=True,
|
|
|
+ skipped=True,
|
|
|
+ reason=f"event_type_not_dispatched:{event.event_type}",
|
|
|
+ user_id=user_id,
|
|
|
+ )
|
|
|
+
|
|
|
+ if not self._auto_create:
|
|
|
+ return RouteResult(ok=False, error="auto_create_trace_disabled", user_id=user_id)
|
|
|
+
|
|
|
+ trace_id = await self._trace.get_or_create_trace(
|
|
|
+ channel=CHANNEL_FEISHU,
|
|
|
+ user_id=user_id,
|
|
|
+ workspace_id=workspace_id,
|
|
|
+ agent_type=self._agent_type,
|
|
|
+ metadata=feishu_event_to_mapping(event),
|
|
|
+ )
|
|
|
+
|
|
|
+ ctx = self._reply_context_from_event(event)
|
|
|
+ if ctx is None:
|
|
|
+ return RouteResult(
|
|
|
+ ok=False,
|
|
|
+ error="missing_chat_id_for_reply",
|
|
|
+ trace_id=trace_id,
|
|
|
+ user_id=user_id,
|
|
|
+ workspace_id=workspace_id,
|
|
|
+ )
|
|
|
+
|
|
|
+ task_id = await self._executor.handle_inbound_message(
|
|
|
+ trace_id,
|
|
|
+ text or "",
|
|
|
+ ctx,
|
|
|
+ self._connector,
|
|
|
+ event=event,
|
|
|
+ )
|
|
|
+ return RouteResult(
|
|
|
+ ok=True,
|
|
|
+ trace_id=trace_id,
|
|
|
+ task_id=task_id,
|
|
|
+ user_id=user_id,
|
|
|
+ workspace_id=workspace_id,
|
|
|
+ )
|
|
|
+
|
|
|
+ async def route_message(self, channel: str, user_id: str, message: Mapping[str, Any]) -> str:
|
|
|
+ """
|
|
|
+ 通用入口:message 建议包含 text、可选飞书上下文字段
|
|
|
+ (account_id, app_id, chat_id, message_id, open_id)。
|
|
|
+ """
|
|
|
+ text = str(message.get("text") or message.get("content") or "")
|
|
|
+ trace_id = await self.get_trace_id(channel, user_id)
|
|
|
+ ctx = FeishuReplyContext(
|
|
|
+ account_id=_as_opt_str(message.get("account_id")),
|
|
|
+ app_id=str(message.get("app_id") or ""),
|
|
|
+ chat_id=str(message.get("chat_id") or ""),
|
|
|
+ message_id=_as_opt_str(message.get("message_id")),
|
|
|
+ open_id=_as_opt_str(message.get("open_id")),
|
|
|
+ )
|
|
|
+ if not ctx.app_id or not ctx.chat_id:
|
|
|
+ raise ValueError("route_message requires app_id and chat_id in message for Feishu reply")
|
|
|
+ synthetic = IncomingFeishuEvent(
|
|
|
+ event_type="message",
|
|
|
+ app_id=ctx.app_id,
|
|
|
+ account_id=ctx.account_id,
|
|
|
+ open_id=ctx.open_id,
|
|
|
+ chat_type=_as_opt_str(message.get("chat_type")),
|
|
|
+ chat_id=ctx.chat_id,
|
|
|
+ message_id=ctx.message_id,
|
|
|
+ content=text,
|
|
|
+ raw=dict(message) if isinstance(message, dict) else {},
|
|
|
+ )
|
|
|
+ return await self._executor.handle_inbound_message(
|
|
|
+ trace_id, text, ctx, self._connector, event=synthetic
|
|
|
+ )
|
|
|
+
|
|
|
+ async def send_agent_reply(
|
|
|
+ self,
|
|
|
+ trace_id: str,
|
|
|
+ content: str,
|
|
|
+ metadata: Mapping[str, Any] | None = None,
|
|
|
+ ) -> dict[str, Any]:
|
|
|
+ """
|
|
|
+ Executor 完成后由业务调用:根据 metadata 中的飞书上下文发消息。
|
|
|
+
|
|
|
+ metadata 键:account_id?, app_id, chat_id, message_id?, open_id?
|
|
|
+ """
|
|
|
+ meta = dict(metadata or {})
|
|
|
+ ctx = FeishuReplyContext(
|
|
|
+ account_id=_as_opt_str(meta.get("account_id")),
|
|
|
+ app_id=str(meta.get("app_id") or ""),
|
|
|
+ chat_id=str(meta.get("chat_id") or ""),
|
|
|
+ message_id=_as_opt_str(meta.get("message_id")),
|
|
|
+ open_id=_as_opt_str(meta.get("open_id")),
|
|
|
+ )
|
|
|
+ if not ctx.chat_id:
|
|
|
+ return {"ok": False, "error": "metadata missing chat_id", "trace_id": trace_id}
|
|
|
+ _ = trace_id
|
|
|
+ return await self._connector.send_text(ctx, content)
|
|
|
+
|
|
|
+
|
|
|
+def _as_opt_str(v: Any) -> str | None:
|
|
|
+ if v is None:
|
|
|
+ return None
|
|
|
+ s = str(v)
|
|
|
+ return s if s else None
|