From 30805ae5dfae7cceb989840d901db94913428b71 Mon Sep 17 00:00:00 2001 From: huashen <2494946808@qq.com> Date: Mon, 17 Aug 2026 18:36:28 +0800 Subject: [PATCH 01/11] feat(feishu): migrate channel to pure v3 --- .github/workflows/plugin-api-v2.yml | 47 - .github/workflows/plugin-api-v3.yml | 66 ++ README.md | 21 +- akashic.plugin.toml | 11 + channel.py | 1503 ++++++++++++++------------- config.py | 65 +- plugin.py | 86 +- requirements.txt | 1 + tests/test_manager_integration.py | 148 +++ tests/test_plugin.py | 640 ++++++++---- 10 files changed, 1537 insertions(+), 1051 deletions(-) delete mode 100644 .github/workflows/plugin-api-v2.yml create mode 100644 .github/workflows/plugin-api-v3.yml create mode 100644 akashic.plugin.toml create mode 100644 requirements.txt create mode 100644 tests/test_manager_integration.py diff --git a/.github/workflows/plugin-api-v2.yml b/.github/workflows/plugin-api-v2.yml deleted file mode 100644 index b30920d..0000000 --- a/.github/workflows/plugin-api-v2.yml +++ /dev/null @@ -1,47 +0,0 @@ -name: plugin-api-v2 - -on: - pull_request: - push: - branches: - - main - -permissions: - contents: read - -jobs: - contract: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: actions/checkout@v4 - with: - repository: akashic-plugins/plugin-contracts - ref: 24543445c7b99ca63fcd90b5828f754a148b184c - path: .plugin-contracts - - uses: actions/setup-python@v5 - with: - python-version: "3.13" - - name: Check Plugin API v2 - env: - PYTHONPATH: .plugin-contracts - run: python -m akashic_plugin_contracts check plugin.py - - host-channel-contract: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: actions/checkout@v4 - with: - repository: kachofugetsu09/akashic-agent - ref: 2bf05e320103bf63b67693d114fd2433fd10a0ea - path: .akashic-agent - - uses: actions/setup-python@v5 - with: - python-version: "3.13" - - name: Install channel contract dependencies - run: python -m pip install -r .akashic-agent/requirements.txt pytest pytest-asyncio - - name: Verify pinned host channel contract - env: - AKASHIC_AGENT_ROOT: ${{ github.workspace }}/.akashic-agent - run: python -m pytest -q tests diff --git a/.github/workflows/plugin-api-v3.yml b/.github/workflows/plugin-api-v3.yml new file mode 100644 index 0000000..14464df --- /dev/null +++ b/.github/workflows/plugin-api-v3.yml @@ -0,0 +1,66 @@ +name: plugin-api-v3 + +on: + pull_request: + push: + branches: + - main + workflow_dispatch: + +permissions: + contents: read + +jobs: + contract: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/checkout@v4 + with: + repository: akashic-plugins/plugin-contracts + ref: 4dd69dd621e029e51e99aa428443fa3a4ec1f6cf + path: .plugin-contracts + - uses: actions/setup-python@v5 + with: + python-version: "3.13" + - name: Check Plugin API v3 + env: + PYTHONPATH: .plugin-contracts + run: python -m akashic_plugin_contracts check plugin.py + + composition-parity: + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@v4 + - uses: actions/checkout@v4 + with: + repository: kachofugetsu09/akashic-agent + ref: 5e58d38d + path: .akashic-core + - uses: actions/setup-python@v5 + with: + python-version: "3.13" + cache: pip + cache-dependency-path: .akashic-core/requirements.txt + - name: Install exact Core runtime + run: | + python -m venv .venv + .venv/bin/python -m pip install \ + -r .akashic-core/requirements.txt \ + -r .akashic-core/requirements-dev.txt \ + -r requirements.txt \ + pytest pytest-asyncio + - name: Verify Feishu v3 composition + env: + AKASHIC_AGENT_ROOT: .akashic-core + PYTHONPATH: .akashic-core + run: .venv/bin/python -m pytest -q tests/ + - name: Check v3 source types + env: + PYTHONPATH: .akashic-core + run: .venv/bin/basedpyright --level error plugin.py channel.py config.py cards.py tests + - name: Compile Python sources + run: python -m compileall -q plugin.py channel.py config.py cards.py tests + - name: Check diff formatting + run: git diff --check diff --git a/README.md b/README.md index 7bbba5d..c002f06 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,20 @@ -# feishu +# Feishu v3 channel -Akashic Feishu channel plugin. +Feishu is a pure v3 `ChannelDefinition`/`ChannelAdapter` plugin. Core owns +inbound admission, identity mapping, `/stop`, turn-stream lifecycle, delivery +identity, and persistent state; this repository only translates Feishu's +provider protocol. + +The first v3 adapter is deliberately text-only. Incoming and outgoing image, +file, and rich-post attachments are returned as deterministic `REJECTED` +without reading a workspace path, importing bytes, or uploading provider data. +The previous v2 installation and its data remain available for an explicit, +append-only migration; no v2 class or ABI is loaded by this artifact. +Feishu owns no plugin database or attachment store, so this migration has no +plugin-side copy/delete step: the existing `config.local.toml` remains the +formal source and Core owns identity/session data. + +Formal startup is the only path that resolves `CredentialRef` through Core's +provider client factory and creates the HTTP/WebSocket client. Candidate +construction and validation never read formal credentials, create an SDK +client, or contact Feishu. diff --git a/akashic.plugin.toml b/akashic.plugin.toml new file mode 100644 index 0000000..f8c65cf --- /dev/null +++ b/akashic.plugin.toml @@ -0,0 +1,11 @@ +schema_version = 1 +name = "feishu" +version = "3.0.0" +api_version = 3 +entrypoint = "plugin.py" + +[[python]] +requirements = "requirements.txt" + +[channel_credentials] +feishu = ["appId", "appSecret", "app_id", "app_secret"] diff --git a/channel.py b/channel.py index f465400..aceddf0 100644 --- a/channel.py +++ b/channel.py @@ -1,10 +1,7 @@ -""" -飞书私聊渠道。 +"""Pure v3 Feishu channel adapter. -- lark_oapi 长连接接收私聊事件(文本 / 图片 / 文件 / 富文本 post) -- REST 发送:最终回复与主动推送走 interactive 卡片(lark_md 渲染 markdown) -- 流式 live 预览:订阅 TurnStarted/StreamDelta/ToolCall 事件,创建并 PATCH 一张卡片 -- /stop 中断、白名单、身份索引、reply 引用上下文,与 Telegram 渠道对齐 +The adapter owns only Feishu protocol translation. Core owns admission, identity, +control, delivery identity, presentation lifecycle, and all persistent state. """ from __future__ import annotations @@ -15,73 +12,74 @@ import threading import time import warnings -from collections.abc import Callable, Coroutine +from collections.abc import Callable, Coroutine, Mapping from dataclasses import dataclass -from pathlib import Path +from datetime import datetime, timezone from typing import Any, cast import httpx -from agent.looping.interrupt import InterruptController -from bus.events import ( - ChannelMessage, - DeliveryReceipt, - InboundMessage, - OutboundMessage, - channel_message_from_outbound, -) -from bus.events_lifecycle import ( - StreamDeltaReady, - ToolCallCompleted, - ToolCallStarted, - TurnStarted, +from agent.plugin_composition.channels import ( + ChannelAdapter, + ChannelCleanupFailure, + ChannelFactoryContext, + ChannelInboundMessage, + ChannelPresentationPorts, + ChannelReady, + ControlResponseBodies, + CredentialRef, + DeliveryStatus, + InboundIdentity, + PresentationReceipt, + ProviderDeliveryReceipt, + ProviderDeliveryRequest, + RawInbound, + StopReceipt, + StreamDeltaPresentation, + ToolPresentation, + TurnOutputCompletedPresentation, + TurnStartedPresentation, + TurnStreamEvent, + TurnStreamEventKind, ) -from bus.queue import MessageBus -from infra.channels.base import AttachmentStore, MessageDeduper, SessionIdentityIndex -from infra.channels.contract import ChannelContext -from infra.channels.delivery import deliver_message_parts + from .cards import ( ToolLiveLine, build_live_card, build_markdown_card, build_summary_card, - format_tool_intent, - format_tool_target, ) logger = logging.getLogger(__name__) _CHANNEL = "feishu" -_SEEN_MSG_MAXSIZE = 500 -_LIVE_STREAM_MIN_CHARS = 200 -_LIVE_STREAM_MIN_INTERVAL_S = 2.0 -_LIVE_MAX_FAILURES = 3 -_LIVE_MAX_BACKOFF_S = 16.0 +_CARD_TEXT_LIMIT = 4000 _WS_RECONNECT_DELAY_S = 5.0 _WS_STOP_TIMEOUT_S = 2.0 -_CARD_TEXT_LIMIT = 4000 -_MESSAGE_MAX_ATTEMPTS = 4 -_RETRY_BASE_DELAY_S = 0.5 -_RETRY_MAX_DELAY_S = 8.0 -# 飞书频控:HTTP 429 一定是限流;以下为常见频控业务码(尽力覆盖,主要仍依赖 429)。 +_REJECTED_HTTP_STATUSES = frozenset({400, 401, 403, 404, 405, 413, 415, 422}) _RATE_LIMIT_CODES = frozenset({99991400, 99991661, 230020, 230027, 11232}) +_CREDENTIAL_ALIASES = { + "app_id": ("appId", "app_id"), + "app_secret": ("appSecret", "app_secret"), +} -@dataclass +@dataclass(slots=True) class _TokenCache: token: str expires_at: float -# 飞书业务错误(code != 0),携带 code 以便判定频控。 class FeishuApiError(RuntimeError): - def __init__(self, code: int, msg: str) -> None: - super().__init__(f"飞书 API 失败 code={code} msg={msg}") + """Represent a provider response with a non-zero Feishu business code.""" + + def __init__(self, code: int, message: str) -> None: + super().__init__(f"飞书 API 失败 code={code} msg={message}") self.code = code class _SdkShutdownLogFilter(logging.Filter): - """仅过滤 SDK 对主动关闭连接产生的错误日志。""" + """Hide only the SDK error emitted by an intentional socket shutdown.""" def __init__(self, stopped: threading.Event) -> None: super().__init__() @@ -94,185 +92,310 @@ def filter(self, record: logging.LogRecord) -> bool: ) -def _is_rate_limited(err: Exception) -> bool: - if isinstance(err, httpx.HTTPStatusError): - return err.response.status_code == 429 - if isinstance(err, FeishuApiError): - return err.code in _RATE_LIMIT_CODES - return False +def build_feishu_channel(context: ChannelFactoryContext) -> ChannelAdapter: + """Build a side-effect-free Feishu adapter for Core's exact binding.""" + if not isinstance(context, ChannelFactoryContext): + raise TypeError("Feishu channel factory 只接受 ChannelFactoryContext") + if context.identity is None: + raise RuntimeError("Feishu v3 channel 需要 Core ChannelIdentityPort") + if context.ingress is None: + raise RuntimeError("Feishu v3 channel 需要 Core ChannelIngressPort") + if context.control is None: + raise RuntimeError("Feishu v3 channel 需要 Core ChannelControlPort") + if context.turn_stream is None: + raise RuntimeError("Feishu v3 channel 需要 Core TurnStreamPort") + return FeishuAdapter(context) -def _retry_after_seconds(err: Exception, default: float) -> float: - if isinstance(err, httpx.HTTPStatusError): - header = err.response.headers.get("Retry-After") - if header: - try: - return max(float(header), default) - except ValueError: - return default - return default +class FeishuAdapter: + """Translate Feishu text, control, delivery, and preview events to C14 ports.""" -class FeishuChannel: name = _CHANNEL - def __init__( - self, - app_id: str, - app_secret: str, - allow_from: list[str] | None = None, - domain: str = "https://open.feishu.cn", - ) -> None: - self._app_id = app_id - self._app_secret = app_secret - self._allow_from = set(allow_from or []) - self._domain = domain.rstrip("/") - self._bus: MessageBus | None = None - self._loop: asyncio.AbstractEventLoop | None = None - self._interrupt_controller: InterruptController | None = None - self._attachments: AttachmentStore | None = None - self._identity_index: SessionIdentityIndex | None = None - self._client = httpx.AsyncClient(timeout=30.0) + def __init__(self, context: ChannelFactoryContext) -> None: + """Freeze only Core references; no credentials, client, SDK, or network is touched.""" + + self._context = context + self._identity = context.identity + self._ingress = context.ingress + self._provider_factory = context.provider_client_factory + self._credentials = context.credentials + self._config = context.config + self._binding_token = context.binding_token + self._domain = _domain(self._config) + self._allow_from = _allow_from(self._config) + + self._presentation: ChannelPresentationPorts | None = None + self._stream_subscription: Any | None = None + self._provider_client: Any | None = None + self._client: httpx.AsyncClient | None = None + self._app_id: str | None = None + self._app_secret: str | None = None self._token: _TokenCache | None = None - self._message_deduper = MessageDeduper(_SEEN_MSG_MAXSIZE) - self._outbound_bound = False - self._events_bound = False - # 长连接线程 + self._loop: asyncio.AbstractEventLoop | None = None + self._started = False + self._stopping = False + self._ws_client: Any | None = None self._ws_loop: asyncio.AbstractEventLoop | None = None self._ws_thread: threading.Thread | None = None self._ws_stopped = threading.Event() self._sdk_logger: logging.Logger | None = None self._sdk_shutdown_filter: logging.Filter | None = None - # live 预览状态 - self._live_messages: dict[str, str] = {} + self._inbound_tasks: set[asyncio.Task[DeliveryStatus | None]] = set() + + self._inbound_recipients: dict[str, str] = {} + self._turn_recipients: dict[str, str] = {} + self._presentation_client_messages: dict[str, str] = {} self._reply_buffers: dict[str, str] = {} self._thinking_buffers: dict[str, str] = {} self._tool_lines: dict[str, list[ToolLiveLine]] = {} - self._live_next_at: dict[str, float] = {} - self._live_last_lengths: dict[str, int] = {} - self._live_failures: dict[str, int] = {} - self._live_interval: dict[str, float] = {} - self._live_backoff_until: dict[str, float] = {} - self._live_disabled: set[str] = set() - self._live_locks: dict[str, asyncio.Lock] = {} - self._live_tasks: set[asyncio.Task[None]] = set() - self._live_tasks_by_session: dict[str, set[asyncio.Task[None]]] = {} - self._inbound_tasks: set[asyncio.Task[None]] = set() - - async def start(self, ctx: ChannelContext) -> None: - if self._client.is_closed: - self._client = httpx.AsyncClient(timeout=30.0) - self._bus = ctx.bus + self._preview_messages: dict[str, str] = {} + self._failed_presentations: set[str] = set() + self._rejected_presentations: set[str] = set() + + def attach_presentation(self, ports: ChannelPresentationPorts) -> None: + """Bind the exact Core control and turn-stream facades before start.""" + + if self._presentation is not None: + raise RuntimeError("Feishu presentation ports 不能重复绑定") + if ports.control is None or ports.turn_stream is None: + raise RuntimeError("Feishu v3 必须同时绑定 control 与 turn_stream") + self._presentation = ports + + async def start(self) -> ChannelReady: + """Resolve formal credentials, create the provider client, and start closed.""" + + if self._started or self._stopping: + raise RuntimeError("Feishu adapter 已启动或正在停止") + if self._presentation is None: + raise RuntimeError("Feishu adapter 缺少 presentation ports") self._loop = asyncio.get_running_loop() - self._interrupt_controller = ctx.interrupt_controller - self._attachments = ctx.attachment_store - self._identity_index = SessionIdentityIndex( - ctx.session_manager, - channel=_CHANNEL, - metadata_key="feishu_open_id", - ) - _ = self._identity_index.rebuild() - if not self._events_bound: - ctx.event_bus.on(TurnStarted, self._on_turn_started) - ctx.event_bus.on(StreamDeltaReady, self._on_stream_delta) - ctx.event_bus.on(ToolCallStarted, self._on_tool_call_started) - ctx.event_bus.on(ToolCallCompleted, self._on_tool_call_completed) - self._events_bound = True - ctx.push_tool.register_channel( - self.name, - deliver=self._deliver_message, - ) - if not self._outbound_bound: - ctx.bus.subscribe_outbound(_CHANNEL, self._on_response) - self._outbound_bound = True - self._ws_stopped.clear() - self._ws_thread = threading.Thread( - target=self._run_ws_client, - name="feishu-ws", - daemon=True, + try: + # 1. Only the formal Host invokes ProviderClientFactory and unwraps refs. + self._provider_client = await self._provider_factory.create(self._credentials) + self._app_id = self._read_credential("app_id") + self._app_secret = self._read_credential("app_secret") + self._client = httpx.AsyncClient(timeout=30.0) + + # 2. Subscribe through the exact Core stream and keep admission closed. + turn_stream = self._presentation.turn_stream + if turn_stream is None: + raise RuntimeError("Feishu turn stream port 未绑定") + self._stream_subscription = turn_stream.subscribe(self._on_turn_stream) + self._ws_stopped.clear() + self._ws_thread = threading.Thread( + target=self._run_ws_client, + name="feishu-ws", + daemon=True, + ) + self._ws_thread.start() + self._started = True + logger.info("[feishu] v3 channel started binding=%s", self._binding_token) + return ChannelReady( + binding_token=self._binding_token, + subscriptions=("feishu.websocket", "feishu.turn_stream"), + admission_open=False, + ) + except BaseException: + await self._close_resources_after_start_failure() + raise + + async def deliver(self, request: ProviderDeliveryRequest) -> ProviderDeliveryReceipt: + """Deliver text only and return a settled provider receipt without retry.""" + + if not isinstance(request, ProviderDeliveryRequest): + raise TypeError("Feishu deliver 只接受 ProviderDeliveryRequest") + if request.binding_token != self._binding_token: + raise RuntimeError("Feishu delivery binding token 不匹配") + if request.attachments: + return ProviderDeliveryReceipt( + request.delivery_id, + DeliveryStatus.REJECTED, + error="Feishu v3 首批 adapter 只支持文本,附件未被读取或上传", + ) + if not request.body.strip(): + return ProviderDeliveryReceipt( + request.delivery_id, + DeliveryStatus.REJECTED, + error="Feishu 空消息被拒绝", + ) + if self._client is None: + raise RuntimeError("Feishu adapter 尚未 start") + + # 1. Split before provider effect; every chunk retains the same delivery id. + provider_ids: list[str] = [] + for chunk in _split_markdown(request.body, _CARD_TEXT_LIMIT): + status, provider_id, error = await self._send_one( + request.recipient, + "interactive", + build_markdown_card(chunk), + ) + if status is DeliveryStatus.REJECTED: + # 2. Only a proven pre-effect card rejection permits the old text fallback. + status, provider_id, error = await self._send_one( + request.recipient, + "text", + json.dumps({"text": chunk}, ensure_ascii=False), + ) + if provider_id: + provider_ids.append(provider_id) + if status is not DeliveryStatus.DELIVERED: + return ProviderDeliveryReceipt( + request.delivery_id, + status, + tuple(provider_ids), + error=error, + ) + return ProviderDeliveryReceipt( + request.delivery_id, + DeliveryStatus.DELIVERED, + tuple(provider_ids), ) - self._ws_thread.start() - logger.info("[feishu] 飞书私聊渠道已启动") - async def stop(self) -> None: + async def stop(self) -> StopReceipt: + """Close stream, websocket, tasks, HTTP client, and provider client exactly once.""" + + if ( + self._stopping + and self._stream_subscription is None + and self._ws_client is None + and self._ws_thread is None + and self._client is None + and self._provider_client is None + ): + return StopReceipt(self._binding_token, resources_closed=True) + self._stopping = True + failures: list[ChannelCleanupFailure] = [] + + # 1. Close Core callback admission and drain accepted callbacks first. + subscription = self._stream_subscription + if subscription is not None: + try: + subscription.close_admission() + await subscription.await_quiescence() + await subscription.close() + self._stream_subscription = None + except BaseException as error: + failures.append(self._cleanup_failure("turn-stream", error)) + + # 2. Stop the provider receive loop before closing its HTTP resources. self._ws_stopped.set() - await self._disconnect_ws() + try: + await self._disconnect_ws() + self._ws_client = None + self._ws_loop = None + except BaseException as error: + failures.append(self._cleanup_failure("websocket-disconnect", error)) thread = self._ws_thread if thread is not None: - await asyncio.to_thread(thread.join, _WS_STOP_TIMEOUT_S) - if thread.is_alive(): - raise RuntimeError("飞书长连接线程停止超时") + try: + await asyncio.to_thread(thread.join, _WS_STOP_TIMEOUT_S) + if thread.is_alive(): + raise RuntimeError("飞书长连接线程停止超时") + self._ws_thread = None + except BaseException as error: + failures.append(self._cleanup_failure("websocket-thread", error)) self._remove_sdk_shutdown_filter() - await asyncio.sleep(0) - await self._drain_inbound_tasks() - await self._drain_live_tasks() - await self._client.aclose() - self._events_bound = False - self._outbound_bound = False - self._ws_client = None - self._ws_loop = None - self._ws_thread = None - logger.info("[feishu] 飞书私聊渠道已停止") - def _require_bus(self) -> MessageBus: - if self._bus is None: - raise RuntimeError("FeishuChannel 尚未启动") - return self._bus + # 3. Complete in-process callback cleanup before returning the receipt. + tasks = tuple(self._inbound_tasks) + for task in tasks: + task.cancel() + if tasks: + await asyncio.gather(*tasks, return_exceptions=True) + self._inbound_tasks.clear() - # ── 长连接 ──────────────────────────────────────────────── + # 4. Release adapter-owned formal resources; Core closes the factory separately. + if self._client is not None: + try: + await self._client.aclose() + except BaseException as error: + failures.append(self._cleanup_failure("http-client", error)) + else: + self._client = None + if self._provider_client is not None: + try: + await self._provider_client.aclose() + except BaseException as error: + failures.append(self._cleanup_failure("provider-client", error)) + else: + self._provider_client = None + + self._app_id = None + self._app_secret = None + self._token = None + closed = not failures + if closed: + self._ws_client = None + self._ws_loop = None + self._ws_thread = None + self._stream_subscription = None + self._started = False + self._stopping = False + logger.info("[feishu] v3 channel stopped binding=%s", self._binding_token) + return StopReceipt(self._binding_token, closed, tuple(failures)) + + # ------------------------------------------------------------------ + # Formal provider receive loop - # 在独立线程跑 lark 长连接,外层包重连循环,避免单次异常后永久失联。 def _run_ws_client(self) -> None: + """Run the SDK receive loop in its own thread without Core state access.""" + loop = asyncio.new_event_loop() asyncio.set_event_loop(loop) - loop.set_exception_handler(self._handle_ws_loop_exception) self._ws_loop = loop + loop.set_exception_handler(self._handle_ws_loop_exception) try: while not self._ws_stopped.is_set(): try: - self._build_ws_client().start() - except Exception as e: + client = self._build_ws_client() + if self._ws_stopped.is_set(): + break + client.start() + except Exception as error: if self._ws_stopped.is_set(): break - logger.warning("[feishu] 长连接退出,准备重连: %s", e) + logger.warning("[feishu] 长连接退出,准备重连: %s", error) if self._ws_stopped.is_set(): break time.sleep(_WS_RECONNECT_DELAY_S) finally: pending = asyncio.all_tasks(loop) for task in pending: - _ = task.cancel() + task.cancel() if pending: - _ = loop.run_until_complete( - asyncio.gather(*pending, return_exceptions=True) - ) + loop.run_until_complete(asyncio.gather(*pending, return_exceptions=True)) loop.close() def _build_ws_client(self) -> Any: + """Create the Feishu SDK socket only after formal credential admission.""" + + if not self._app_id or not self._app_secret: + raise RuntimeError("Feishu websocket 缺少 formal credentials") with warnings.catch_warnings(): warnings.filterwarnings( "ignore", message=r"^pkg_resources is deprecated as an API\.", category=UserWarning, ) - from lark_oapi.core.enum import LogLevel - from lark_oapi.core.log import logger as sdk_logger - from lark_oapi.event.dispatcher_handler import EventDispatcherHandler - from lark_oapi.ws import Client as WsClient + from lark_oapi.core.enum import LogLevel # pyright: ignore[reportMissingImports] + from lark_oapi.core.log import logger as sdk_logger # pyright: ignore[reportMissingImports] + from lark_oapi.event.dispatcher_handler import EventDispatcherHandler # pyright: ignore[reportMissingImports] + from lark_oapi.ws import Client as WsClient # pyright: ignore[reportMissingImports] self._remove_sdk_shutdown_filter() shutdown_filter = _SdkShutdownLogFilter(self._ws_stopped) sdk_logger.addFilter(shutdown_filter) self._sdk_logger = sdk_logger self._sdk_shutdown_filter = shutdown_filter - handler = ( EventDispatcherHandler.builder("", "") .register_p2_im_message_receive_v1(self._on_sdk_message) .build() ) - ws_client = WsClient( + client = WsClient( self._app_id, self._app_secret, log_level=LogLevel.INFO, @@ -280,17 +403,16 @@ def _build_ws_client(self) -> Any: domain=self._domain, auto_reconnect=False, ) - self._ws_client = ws_client - return ws_client + self._ws_client = client + return client def _handle_ws_loop_exception( self, loop: asyncio.AbstractEventLoop, context: dict[str, Any], ) -> None: - if self._ws_stopped.is_set(): - return - loop.default_exception_handler(context) + if not self._ws_stopped.is_set(): + loop.default_exception_handler(context) def _remove_sdk_shutdown_filter(self) -> None: if self._sdk_logger is not None and self._sdk_shutdown_filter is not None: @@ -299,24 +421,21 @@ def _remove_sdk_shutdown_filter(self) -> None: self._sdk_shutdown_filter = None async def _disconnect_ws(self) -> None: - ws_client = self._ws_client - ws_loop = self._ws_loop - if ws_client is None: + client = self._ws_client + loop = self._ws_loop + if client is None: return - if ws_loop is None: + if loop is None: raise RuntimeError("飞书长连接缺少事件循环") - raw_disconnect = getattr(ws_client, "_disconnect", None) - if not callable(raw_disconnect): + disconnect = getattr(client, "_disconnect", None) + if not callable(disconnect): raise RuntimeError("飞书 SDK 不支持主动断开长连接") - disconnect = cast(Callable[[], Coroutine[Any, Any, None]], raw_disconnect) - future = asyncio.run_coroutine_threadsafe(disconnect(), ws_loop) + disconnect_coro = cast(Coroutine[Any, Any, Any], disconnect()) + future = asyncio.run_coroutine_threadsafe(disconnect_coro, loop) try: - await asyncio.wait_for( - asyncio.wrap_future(future), - timeout=_WS_STOP_TIMEOUT_S, - ) + await asyncio.wait_for(asyncio.wrap_future(future), _WS_STOP_TIMEOUT_S) finally: - ws_loop.call_soon_threadsafe(ws_loop.stop) + loop.call_soon_threadsafe(loop.stop) def _on_sdk_message(self, event: Any) -> None: loop = self._loop @@ -327,41 +446,46 @@ def _on_sdk_message(self, event: Any) -> None: def _start_inbound_task(self, event: Any) -> None: if self._ws_stopped.is_set(): return - task = asyncio.create_task(self._handle_message_event(event)) + task = asyncio.create_task( + self._handle_message_event(event), + name="feishu-inbound", + ) self._inbound_tasks.add(task) task.add_done_callback(self._inbound_tasks.discard) - async def _drain_inbound_tasks(self) -> None: - tasks = tuple(self._inbound_tasks) - for task in tasks: - _ = task.cancel() - if tasks: - await asyncio.gather(*tasks, return_exceptions=True) - - # ── 入站 ────────────────────────────────────────────────── + async def _handle_message_event(self, event: Any) -> DeliveryStatus | None: + """Project one SDK event into text ingress or an exact Core control port.""" - async def _handle_message_event(self, event: Any) -> None: data = getattr(event, "event", None) message = getattr(data, "message", None) sender = getattr(data, "sender", None) if message is None or sender is None: - return + return None if str(getattr(message, "chat_type", "") or "") != "p2p": - return - message_id = str(getattr(message, "message_id", "") or "") - if message_id and self._message_deduper.seen(message_id): - return + return None + message_id = str(getattr(message, "message_id", "") or "").strip() + if not message_id: + logger.warning("[feishu] 丢弃缺少 provider message id 的事件") + return DeliveryStatus.REJECTED sender_id = getattr(sender, "sender_id", None) - open_id = str(getattr(sender_id, "open_id", "") or "") - user_id = str(getattr(sender_id, "user_id", "") or "") - union_id = str(getattr(sender_id, "union_id", "") or "") - if self._allow_from and not ({open_id, user_id, union_id} & self._allow_from): - logger.warning("[feishu] 拒绝未授权私聊用户 open_id=%s user_id=%s", open_id, user_id) - return - chat_id = str(getattr(message, "chat_id", "") or "") + open_id = str(getattr(sender_id, "open_id", "") or "").strip() + user_id = str(getattr(sender_id, "user_id", "") or "").strip() + union_id = str(getattr(sender_id, "union_id", "") or "").strip() + identities = {open_id, user_id, union_id} - {""} + if self._allow_from and not identities.intersection(self._allow_from): + logger.warning("[feishu] 拒绝未授权私聊用户 open_id=%s", open_id) + return DeliveryStatus.REJECTED + chat_id = str(getattr(message, "chat_id", "") or "").strip() if not chat_id: - return - await self._ingest_message(message, message_id, chat_id, open_id, user_id, union_id) + return DeliveryStatus.REJECTED + return await self._ingest_message( + message, + message_id, + chat_id, + open_id, + user_id, + union_id, + ) async def _ingest_message( self, @@ -371,539 +495,506 @@ async def _ingest_message( open_id: str, user_id: str, union_id: str, - ) -> None: - text, media = await self._extract_message_payload(message, message_id) - sender = open_id or user_id or union_id - if text == "/stop": - await self._handle_stop(chat_id, sender) - return - if not text and not media: - return - inbound_text, reply_meta = await self._merge_reply_context(message, text) - if self._identity_index is not None and open_id: - await self._identity_index.remember(open_id, chat_id) - await self._require_bus().publish_inbound( - InboundMessage( - channel=_CHANNEL, - sender=sender, - chat_id=chat_id, - content=inbound_text, - media=media, - metadata={ - "chat_type": "private", - "message_id": message_id, - "open_id": open_id, - "user_id": user_id, - "union_id": union_id, - **reply_meta, - }, + ) -> DeliveryStatus: + """Admit text or return deterministic REJECTED for unsupported attachments.""" + + message_type = str(getattr(message, "message_type", "") or "") + if message_type != "text": + logger.info( + "[feishu] v3 attachment input rejected message_id=%s type=%s", + message_id, + message_type, ) + return DeliveryStatus.REJECTED + content = _extract_text(str(getattr(message, "content", "") or "")) + if not content: + return DeliveryStatus.REJECTED + sender = open_id or user_id or union_id + if not sender: + return DeliveryStatus.REJECTED + inbound_text, reply_meta = await self._merge_reply_context(message, content) + raw_message = ChannelInboundMessage( + channel=_CHANNEL, + sender=sender, + chat_id=chat_id, + content=inbound_text, + timestamp=_message_timestamp(message), + metadata={ + "chat_type": "private", + "provider_message_id": message_id, + "open_id": open_id, + "user_id": user_id, + "union_id": union_id, + **reply_meta, + }, ) + raw = RawInbound( + message_id=message_id, + message=raw_message, + provider_identity=sender, + recipient=chat_id, + ) + if content.strip() == "/stop": + return await self._interrupt(raw) + if self._ingress is None: + raise RuntimeError("Feishu ingress port 未绑定") + accepted = await self._ingress.admit(raw) + if accepted: + self._inbound_recipients[message_id] = chat_id + return DeliveryStatus.DELIVERED + return DeliveryStatus.REJECTED + + async def _interrupt(self, raw: RawInbound) -> DeliveryStatus: + """Delegate /stop to Core's exact control facade; never call an old controller.""" + + if self._presentation is None or self._presentation.control is None: + raise RuntimeError("Feishu control port 未绑定") + result = await self._presentation.control.interrupt( + raw, + response_bodies=ControlResponseBodies( + interrupted="已停止当前任务。", + idle="当前没有正在运行的任务。", + ), + ) + if result.response is None: + return DeliveryStatus.REJECTED + return result.response.status - # 按消息类型解析文本与媒体;图片/文件会下载落盘到 AttachmentStore。 - async def _extract_message_payload( - self, - message: Any, - message_id: str, - ) -> tuple[str, list[str]]: - msg_type = str(getattr(message, "message_type", "") or "") - content_raw = str(getattr(message, "content", "") or "") - if msg_type == "text": - return _extract_text(content_raw), [] - if msg_type == "image": - image_key = _extract_key(content_raw, "image_key") - path = await self._download_resource(message_id, image_key, "image", ".jpg") - return "[图片]", [path] if path else [] - if msg_type == "file": - file_key = _extract_key(content_raw, "file_key") - file_name = _extract_key(content_raw, "file_name") or "file" - suffix = "." + file_name.rsplit(".", 1)[-1] if "." in file_name else "" - path = await self._download_resource(message_id, file_key, "file", suffix) - return f"[文件: {file_name}]", [path] if path else [] - if msg_type == "post": - text, image_keys = _extract_post(content_raw) - media: list[str] = [] - for key in image_keys: - path = await self._download_resource(message_id, key, "image", ".jpg") - if path: - media.append(path) - return text or "[富文本]", media - logger.debug("[feishu] 暂不支持的消息类型 msg_type=%s", msg_type) - return "", [] - - # 若消息回复了历史消息,拉取父消息文本并合并入站,避免 agent 丢失引用。 async def _merge_reply_context( self, message: Any, text: str, ) -> tuple[str, dict[str, str]]: - parent_id = str(getattr(message, "parent_id", "") or "") + parent_id = str(getattr(message, "parent_id", "") or "").strip() if not parent_id: return text, {} - reply_text = await self._fetch_message_text(parent_id) - if not reply_text: + parent_text = await self._fetch_message_text(parent_id) + if not parent_text: return text, {"reply_to_message_id": parent_id} - merged = ( - "【你正在回复一条历史消息】\n" - f"被回复消息:\n{reply_text}\n\n" - "【你当前新消息】\n" - f"{text}" - ).strip() - return merged, {"reply_to_message_id": parent_id} - - async def _handle_stop(self, chat_id: str, sender: str) -> None: - if self._interrupt_controller is None: - await self.send(chat_id, "当前未启用中断功能。") - return - result = self._interrupt_controller.request_interrupt( - session_key=f"{_CHANNEL}:{chat_id}", - sender=sender, - command="/stop", + return ( + ( + "【你正在回复一条历史消息】\n" + f"被回复消息:\n{parent_text}\n\n" + "【你当前新消息】\n" + f"{text}" + ).strip(), + {"reply_to_message_id": parent_id}, ) - await self.send(chat_id, result.message) - # ── live 预览(卡片)──────────────────────────────────────── + # ------------------------------------------------------------------ + # Typed turn stream and remote preview - async def _on_turn_started(self, event: TurnStarted) -> None: - if event.channel != _CHANNEL: - return - await self._cancel_live_tasks(event.session_key) - self._clear_live_session(event.session_key) + async def _on_turn_stream(self, event: TurnStreamEvent) -> PresentationReceipt: + """Render one typed event into the same Feishu preview artifact.""" - async def _on_stream_delta(self, event: StreamDeltaReady) -> None: - if event.channel != _CHANNEL: - return - if not event.content_delta and not event.thinking_delta: - return - if event.content_delta: - self._reply_buffers[event.session_key] = ( - self._reply_buffers.get(event.session_key, "") + event.content_delta + if event.presentation_id in self._failed_presentations: + receipt = self._presentation_receipt( + event, + DeliveryStatus.UNKNOWN, + "preview 已终止", ) - if event.thinking_delta: - self._thinking_buffers[event.session_key] = ( - self._thinking_buffers.get(event.session_key, "") + event.thinking_delta + if event.kind is TurnStreamEventKind.TURN_OUTPUT_COMPLETED: + self._clear_presentation(event.presentation_id, _turn_id(event)) + return receipt + if event.presentation_id in self._rejected_presentations: + receipt = self._presentation_receipt( + event, + DeliveryStatus.REJECTED, + "preview 已拒绝", ) - live_len = len(self._reply_buffers.get(event.session_key, "")) + len( - self._thinking_buffers.get(event.session_key, "") - ) - last_len = self._live_last_lengths.get(event.session_key, 0) - now = asyncio.get_running_loop().time() - next_at = self._live_next_at.get(event.session_key, 0.0) - if now < next_at and live_len - last_len < _LIVE_STREAM_MIN_CHARS: - return - self._live_next_at[event.session_key] = now + _LIVE_STREAM_MIN_INTERVAL_S - self._live_last_lengths[event.session_key] = live_len - self._start_live_task( - event.session_key, - self._sync_live_card(event.session_key, event.chat_id), - ) - - async def _on_tool_call_started(self, event: ToolCallStarted) -> None: - if event.channel != _CHANNEL: - return - lines = self._tool_lines.setdefault(event.session_key, []) - lines.append( - ToolLiveLine( - call_id=event.call_id, - tool_name=event.tool_name, - intent=format_tool_intent(event.arguments), - target=format_tool_target(event.arguments), + if event.kind is TurnStreamEventKind.TURN_OUTPUT_COMPLETED: + self._clear_presentation(event.presentation_id, _turn_id(event)) + return receipt + try: + if event.kind is TurnStreamEventKind.TURN_STARTED: + payload = cast(TurnStartedPresentation, event.payload) + recipient = self._inbound_recipients.get(payload.client_message_id) + if recipient is None and self._identity is not None: + recipient = self._identity.resolve(payload.client_message_id) + if not recipient: + return self._reject_presentation( + event, + "turn.started 缺少已接受 inbound recipient", + ) + self._turn_recipients[payload.turn_id] = recipient + self._presentation_client_messages[event.presentation_id] = ( + payload.client_message_id + ) + self._reply_buffers[event.presentation_id] = "" + self._thinking_buffers[event.presentation_id] = "" + self._tool_lines[event.presentation_id] = [] + return await self._sync_preview(event, recipient, live=True) + + recipient = self._turn_recipients.get(_turn_id(event)) + if not recipient: + return self._reject_presentation(event, "turn stream 缺少 turn.started") + if event.kind is TurnStreamEventKind.STREAM_DELTA: + payload = cast(StreamDeltaPresentation, event.payload) + self._reply_buffers[event.presentation_id] = ( + self._reply_buffers.get(event.presentation_id, "") + payload.text_delta + ) + self._thinking_buffers[event.presentation_id] = ( + self._thinking_buffers.get(event.presentation_id, "") + + payload.reasoning_delta + ) + return await self._sync_preview(event, recipient, live=True) + if event.kind in { + TurnStreamEventKind.TOOL_STARTED, + TurnStreamEventKind.TOOL_COMPLETED, + }: + payload = cast(ToolPresentation, event.payload) + lines = self._tool_lines.setdefault(event.presentation_id, []) + line = next((item for item in lines if item.call_id == payload.tool_call_id), None) + if line is None: + line = ToolLiveLine( + call_id=payload.tool_call_id, + tool_name=payload.tool_name, + intent="", + target="", + ) + lines.append(line) + line.status = "running" if event.kind is TurnStreamEventKind.TOOL_STARTED else "done" + return await self._sync_preview(event, recipient, live=True) + + payload = cast(TurnOutputCompletedPresentation, event.payload) + try: + return await self._sync_preview(event, recipient, live=False) + finally: + self._clear_presentation(event.presentation_id, payload.turn_id) + except asyncio.CancelledError: + self._failed_presentations.add(event.presentation_id) + raise + except Exception as error: + self._failed_presentations.add(event.presentation_id) + logger.warning( + "[feishu] preview failed presentation=%s err=%s", + event.presentation_id, + error, ) - ) - self._start_live_task( - event.session_key, - self._sync_live_card(event.session_key, event.chat_id), - ) + return self._presentation_receipt(event, DeliveryStatus.UNKNOWN, str(error)) - async def _on_tool_call_completed(self, event: ToolCallCompleted) -> None: - if event.channel != _CHANNEL: - return - lines = self._tool_lines.setdefault(event.session_key, []) - line = next((item for item in lines if item.call_id == event.call_id), None) - if line is None: - line = ToolLiveLine( - call_id=event.call_id, - tool_name=event.tool_name, - intent=format_tool_intent(event.final_arguments or event.arguments), - target=format_tool_target(event.final_arguments or event.arguments), + async def _sync_preview( + self, + event: TurnStreamEvent, + recipient: str, + *, + live: bool, + ) -> PresentationReceipt: + card = ( + build_live_card( + self._thinking_buffers.get(event.presentation_id, ""), + self._tool_lines.get(event.presentation_id, []), + self._reply_buffers.get(event.presentation_id, ""), + ) + if live + else build_summary_card( + self._thinking_buffers.get(event.presentation_id, ""), + self._tool_lines.get(event.presentation_id, []), ) - lines.append(line) - line.status = "error" if event.status == "error" else "done" - self._start_live_task( - event.session_key, - self._sync_live_card(event.session_key, event.chat_id), - ) - - # 创建或 PATCH live 卡片;失败累计后禁用该会话的 live,回退一次性发送。 - async def _sync_live_card(self, session_key: str, chat_id: str) -> None: - if session_key in self._live_disabled: - return - if asyncio.get_running_loop().time() < self._live_backoff_until.get(session_key, 0.0): - return - card = build_live_card( - self._thinking_buffers.get(session_key, ""), - self._tool_lines.get(session_key, []), - self._reply_buffers.get(session_key, ""), ) - lock = self._live_locks.setdefault(session_key, asyncio.Lock()) - async with lock: - if session_key in self._live_disabled: - return - try: - await self._upsert_live_card(session_key, chat_id, card) - except Exception as e: - self._record_live_failure(session_key, e) - - async def _upsert_live_card(self, session_key: str, chat_id: str, card: str) -> None: - message_id = self._live_messages.get(session_key) + message_id = self._preview_messages.get(event.presentation_id) if message_id is None: - data = await self._post_message_once(chat_id, "interactive", card) - new_id = str(data.get("message_id") or "") - if new_id: - self._live_messages[session_key] = new_id - else: - _ = await self._patch_message_once(message_id, card) - # 成功即重置失败计数与自适应间隔 - self._live_failures[session_key] = 0 - self._live_interval[session_key] = _LIVE_STREAM_MIN_INTERVAL_S - - # live 刷新失败处理:频控走自适应退避降频(间隔翻倍),其余错误累计后禁用 live 回退。 - def _record_live_failure(self, session_key: str, err: Exception) -> None: - if _is_rate_limited(err): - interval = min( - self._live_interval.get(session_key, _LIVE_STREAM_MIN_INTERVAL_S) * 2, - _LIVE_MAX_BACKOFF_S, + status, provider_id, error = await self._send_one( + recipient, + "interactive", + card, ) - self._live_interval[session_key] = interval - self._live_backoff_until[session_key] = asyncio.get_running_loop().time() + interval - logger.warning("[feishu] live 命中频控,退避降频 session=%s 下次间隔=%.1fs", session_key, interval) - return - failures = self._live_failures.get(session_key, 0) + 1 - self._live_failures[session_key] = failures - if failures >= _LIVE_MAX_FAILURES: - self._live_disabled.add(session_key) - logger.warning( - "[feishu] live 卡片刷新失败 session=%s failures=%d disabled=%s err=%s", - session_key, - failures, - session_key in self._live_disabled, - err, - ) - - # ── 出站 ────────────────────────────────────────────────── - - async def _on_response(self, msg: OutboundMessage) -> None: - session_key = f"{_CHANNEL}:{msg.chat_id}" - thinking = self._final_thinking_text(session_key, msg.thinking) - tool_lines = self._tool_lines.get(session_key, []) - if session_key in self._live_messages: - await self._cancel_live_tasks(session_key) - # 1. 把实时预览卡原地定格为"过程"卡(思考折叠 + 工具),不撤回 - await self._freeze_live_card(session_key, msg.chat_id, thinking, tool_lines) - # 2. 通过统一 adapter 提交正文与附件,并让失败继续向上游传播 - receipt = await self._deliver_message(channel_message_from_outbound(msg)) - self._clear_live_session(session_key) - if not receipt.succeeded: - raise RuntimeError(receipt.detail or "飞书消息提交失败") - - # 把实时预览卡 PATCH 成过程卡(思考折叠 + 工具时间线);无预览卡但有过程则新发一张。不撤回。 - async def _freeze_live_card( - self, - session_key: str, - chat_id: str, - thinking: str, - tool_lines: list[ToolLiveLine], - ) -> None: - if not thinking.strip() and not tool_lines: - return - summary = build_summary_card(thinking, tool_lines) - message_id = self._live_messages.get(session_key) - if message_id is not None: - try: - _ = await self._patch_message_once(message_id, summary) - return - except Exception as e: - logger.warning("[feishu] 过程卡定格失败,改为新发: %s", e) - await self._post_card_or_text(chat_id, summary, thinking) - - def _final_thinking_text(self, session_key: str, thinking: str | None) -> str: - streamed = self._thinking_buffers.get(session_key, "").strip() - final = (thinking or "").strip() - if streamed and final: - if final in streamed: - return streamed - if streamed in final: - return final - return f"{streamed}\n\n{final}" - return streamed or final - - # 文本消息(供 MessagePushTool 调用):走卡片渲染 markdown,超长分块、失败降级纯文本。 - async def send(self, chat_id: str, text: str) -> None: - if not text.strip(): - return - for chunk in _split_markdown(text, _CARD_TEXT_LIMIT): - await self._post_card_or_text(chat_id, build_markdown_card(chunk), chunk) - - # 发送卡片;渲染/大小异常时降级为 msg_type text,保证消息不丢(对齐 Telegram 降级哲学)。 - async def _post_card_or_text(self, chat_id: str, card: str, fallback_text: str) -> None: - try: - _ = await self._post_message(chat_id, "interactive", card) - except Exception as e: - logger.warning("[feishu] 卡片发送失败,降级纯文本: %s", e) - if fallback_text.strip(): - content = json.dumps({"text": fallback_text}, ensure_ascii=False) - _ = await self._post_message(chat_id, "text", content) - - async def send_stream(self, chat_id: str, text: str) -> None: - await self.send(chat_id, text) - - async def send_image(self, chat_id: str, image: str) -> None: - if image.startswith(("http://", "https://")): - resp = await self._client.get(image) - _ = resp.raise_for_status() - data = resp.content - else: - data = Path(image).read_bytes() - image_key = await self._upload_image(data) - content = json.dumps({"image_key": image_key}, ensure_ascii=False) - _ = await self._post_message(chat_id, "image", content) - - async def send_file( + if status is DeliveryStatus.DELIVERED and provider_id: + self._preview_messages[event.presentation_id] = provider_id + elif status is DeliveryStatus.UNKNOWN: + self._failed_presentations.add(event.presentation_id) + elif status is DeliveryStatus.REJECTED: + self._rejected_presentations.add(event.presentation_id) + return self._presentation_receipt(event, status, error, provider_id) + + status, error = await self._patch_one(message_id, card) + if status is DeliveryStatus.UNKNOWN: + self._failed_presentations.add(event.presentation_id) + elif status is DeliveryStatus.REJECTED: + self._rejected_presentations.add(event.presentation_id) + return self._presentation_receipt(event, status, error, message_id) + + def _presentation_receipt( self, - chat_id: str, - file_path: str, - name: str | None = None, - caption: str | None = None, - ) -> None: - path = Path(file_path) - file_name = name or path.name - file_key = await self._upload_file(path.read_bytes(), file_name) - content = json.dumps({"file_key": file_key}, ensure_ascii=False) - _ = await self._post_message(chat_id, "file", content) - if caption and caption.strip(): - await self.send(chat_id, caption) - - async def _deliver_message(self, message: ChannelMessage) -> DeliveryReceipt: - """以飞书原生调用提交完整消息并报告部分送达。""" - - return await deliver_message_parts( - message, - send_text=self.send, - send_file=self.send_file, - send_image=self.send_image, + event: TurnStreamEvent, + status: DeliveryStatus, + error: str | None = None, + provider_id: str | None = None, + ) -> PresentationReceipt: + return PresentationReceipt( + presentation_id=event.presentation_id, + status=status, + provider_ids=(provider_id,) if provider_id else (), + error=error, ) - # ── live 任务管理 ────────────────────────────────────────── - - def _start_live_task(self, session_key: str, coro: Coroutine[Any, Any, None]) -> None: - task = asyncio.create_task(coro) - self._live_tasks.add(task) - self._live_tasks_by_session.setdefault(session_key, set()).add(task) - task.add_done_callback(lambda done: self._on_live_task_done(session_key, done)) - - def _on_live_task_done(self, session_key: str, task: asyncio.Task[None]) -> None: - self._live_tasks.discard(task) - tasks = self._live_tasks_by_session.get(session_key) - if tasks is not None: - tasks.discard(task) - if not tasks: - _ = self._live_tasks_by_session.pop(session_key, None) - if task.cancelled(): - return - exc = task.exception() - if exc is not None: - logger.debug("[feishu] live 任务异常: %s", exc) + def _reject_presentation(self, event: TurnStreamEvent, error: str) -> PresentationReceipt: + self._rejected_presentations.add(event.presentation_id) + return self._presentation_receipt(event, DeliveryStatus.REJECTED, error) - async def _cancel_live_tasks(self, session_key: str) -> None: - tasks = list(self._live_tasks_by_session.get(session_key, set())) - for task in tasks: - _ = task.cancel() - if tasks: - _ = await asyncio.gather(*tasks, return_exceptions=True) - - async def _drain_live_tasks(self) -> None: - tasks = [task for task in self._live_tasks if not task.done()] - if tasks: - _ = await asyncio.gather(*tasks, return_exceptions=True) - - def _clear_live_session(self, session_key: str) -> None: - _ = self._live_messages.pop(session_key, None) - _ = self._reply_buffers.pop(session_key, None) - _ = self._thinking_buffers.pop(session_key, None) - _ = self._tool_lines.pop(session_key, None) - _ = self._live_next_at.pop(session_key, None) - _ = self._live_last_lengths.pop(session_key, None) - _ = self._live_failures.pop(session_key, None) - _ = self._live_interval.pop(session_key, None) - _ = self._live_backoff_until.pop(session_key, None) - self._live_disabled.discard(session_key) - _ = self._live_locks.pop(session_key, None) - - # ── REST ────────────────────────────────────────────────── - - def _resolve_receive(self, chat_id: str) -> tuple[str, str]: - value = chat_id.strip() - if value.startswith(f"{_CHANNEL}:"): - value = value[len(_CHANNEL) + 1:] - if value.startswith("oc_"): - return value, "chat_id" - if self._identity_index is not None: - resolved = self._identity_index.resolve(value) - if resolved: - return resolved, "chat_id" - if value.startswith("ou_"): - return value, "open_id" - if value.startswith("on_"): - return value, "union_id" - return value, "chat_id" + def _clear_presentation(self, presentation_id: str, turn_id: str) -> None: + """Release one completed preview and its temporary inbound binding.""" - # 单次发送,无重试:供 live 卡片使用,撞频控时丢帧保实时(对齐 Telegram live max_attempts=1)。 - async def _post_message_once(self, chat_id: str, msg_type: str, content: str) -> dict[str, Any]: - receive_id, receive_id_type = self._resolve_receive(chat_id) + client_message_id = self._presentation_client_messages.pop( + presentation_id, + None, + ) + if client_message_id is not None: + self._inbound_recipients.pop(client_message_id, None) + self._turn_recipients.pop(turn_id, None) + self._reply_buffers.pop(presentation_id, None) + self._thinking_buffers.pop(presentation_id, None) + self._tool_lines.pop(presentation_id, None) + self._preview_messages.pop(presentation_id, None) + self._failed_presentations.discard(presentation_id) + self._rejected_presentations.discard(presentation_id) + + # ------------------------------------------------------------------ + # REST and delivery classifier + + async def _send_one( + self, + recipient: str, + message_type: str, + content: str, + ) -> tuple[DeliveryStatus, str | None, str | None]: + try: + payload = await self._post_message_once(recipient, message_type, content) + except asyncio.CancelledError: + raise + except httpx.HTTPStatusError as error: + status = error.response.status_code + if status in _REJECTED_HTTP_STATUSES: + return DeliveryStatus.REJECTED, None, f"HTTP {status}" + return DeliveryStatus.UNKNOWN, None, f"HTTP {status}" + except FeishuApiError as error: + if error.code in _RATE_LIMIT_CODES: + return DeliveryStatus.UNKNOWN, None, str(error) + return DeliveryStatus.REJECTED, None, str(error) + except Exception as error: + return DeliveryStatus.UNKNOWN, None, str(error) or type(error).__name__ + provider_id = str(payload.get("message_id") or "").strip() + if not provider_id: + return DeliveryStatus.UNKNOWN, None, "Feishu response 缺少 message_id" + return DeliveryStatus.DELIVERED, provider_id, None + + async def _patch_one( + self, + message_id: str, + content: str, + ) -> tuple[DeliveryStatus, str | None]: + try: + await self._patch_message_once(message_id, content) + except asyncio.CancelledError: + raise + except httpx.HTTPStatusError as error: + status = error.response.status_code + if status in _REJECTED_HTTP_STATUSES: + return DeliveryStatus.REJECTED, f"HTTP {status}" + return DeliveryStatus.UNKNOWN, f"HTTP {status}" + except FeishuApiError as error: + if error.code in _RATE_LIMIT_CODES: + return DeliveryStatus.UNKNOWN, str(error) + return DeliveryStatus.REJECTED, str(error) + except Exception as error: + return DeliveryStatus.UNKNOWN, str(error) or type(error).__name__ + return DeliveryStatus.DELIVERED, None + + async def _post_message_once( + self, + recipient: str, + message_type: str, + content: str, + ) -> dict[str, Any]: + if self._client is None: + raise RuntimeError("Feishu HTTP client 尚未 start") + receive_id, receive_id_type = self._resolve_receive(recipient) token = await self._get_access_token() - resp = await self._client.post( + response = await self._client.post( f"{self._domain}/open-apis/im/v1/messages", params={"receive_id_type": receive_id_type}, headers={"Authorization": f"Bearer {token}"}, - json={"receive_id": receive_id, "msg_type": msg_type, "content": content}, + json={ + "receive_id": receive_id, + "msg_type": message_type, + "content": content, + }, ) - return self._check_response(resp) + return self._check_response(response) async def _patch_message_once(self, message_id: str, content: str) -> dict[str, Any]: + if self._client is None: + raise RuntimeError("Feishu HTTP client 尚未 start") token = await self._get_access_token() - resp = await self._client.patch( + response = await self._client.patch( f"{self._domain}/open-apis/im/v1/messages/{message_id}", headers={"Authorization": f"Bearer {token}"}, json={"content": content}, ) - return self._check_response(resp) - - async def _post_message(self, chat_id: str, msg_type: str, content: str) -> dict[str, Any]: - return await self._with_rate_limit_retry( - lambda: self._post_message_once(chat_id, msg_type, content), - label="post_message", - ) + return self._check_response(response) async def _fetch_message_text(self, message_id: str) -> str: + if self._client is None: + return "" try: token = await self._get_access_token() - resp = await self._client.get( + response = await self._client.get( f"{self._domain}/open-apis/im/v1/messages/{message_id}", headers={"Authorization": f"Bearer {token}"}, ) - data = self._check_response(resp) - except Exception as e: - logger.debug("[feishu] 拉取父消息失败 id=%s err=%s", message_id, e) + payload = self._check_response(response) + except asyncio.CancelledError: + raise + except Exception as error: + logger.debug("[feishu] 拉取父消息失败 id=%s err=%s", message_id, error) return "" - items = data.get("items") - if not isinstance(items, list) or not items: + items = payload.get("items") + if not isinstance(items, list) or not items or not isinstance(items[0], dict): return "" - first = cast(dict[str, Any], items[0]) if isinstance(items[0], dict) else {} - body = first.get("body") + body = items[0].get("body") if not isinstance(body, dict): return "" - return _extract_text(str(cast(dict[str, Any], body).get("content") or "")) + return _extract_text(str(body.get("content") or "")) - async def _download_resource( - self, - message_id: str, - file_key: str, - resource_type: str, - suffix: str, - ) -> str | None: - if not file_key or self._attachments is None: - return None - try: - token = await self._get_access_token() - resp = await self._client.get( - f"{self._domain}/open-apis/im/v1/messages/{message_id}/resources/{file_key}", - params={"type": resource_type}, - headers={"Authorization": f"Bearer {token}"}, - ) - _ = resp.raise_for_status() - except Exception as e: - logger.warning("[feishu] 资源下载失败 key=%s err=%s", file_key, e) - return None - path = self._attachments.write_bytes( - resp.content, - prefix=f"feishu_{resource_type}_", - suffix=suffix, - ) - return str(path) - - async def _upload_image(self, data: bytes) -> str: - token = await self._get_access_token() - resp = await self._client.post( - f"{self._domain}/open-apis/im/v1/images", - headers={"Authorization": f"Bearer {token}"}, - data={"image_type": "message"}, - files={"image": ("image", data)}, - ) - payload = self._check_response(resp) - return str(payload.get("image_key") or "") + def _resolve_receive(self, recipient: str) -> tuple[str, str]: + value = recipient.strip() + if value.startswith(f"{_CHANNEL}:"): + value = value[len(_CHANNEL) + 1 :] + if value.startswith("oc_"): + return value, "chat_id" + if self._identity is not None: + resolved = self._identity.resolve(value) + if resolved: + return resolved, "chat_id" + if value.startswith("ou_"): + return value, "open_id" + if value.startswith("on_"): + return value, "union_id" + return value, "chat_id" - async def _upload_file(self, data: bytes, file_name: str) -> str: - token = await self._get_access_token() - resp = await self._client.post( - f"{self._domain}/open-apis/im/v1/files", - headers={"Authorization": f"Bearer {token}"}, - data={"file_type": "stream", "file_name": file_name}, - files={"file": (file_name, data)}, + async def _get_access_token(self) -> str: + if self._client is None or not self._app_id or not self._app_secret: + raise RuntimeError("Feishu formal credentials/client 未就绪") + if self._token is not None and self._token.expires_at > time.time() + 60: + return self._token.token + response = await self._client.post( + f"{self._domain}/open-apis/auth/v3/tenant_access_token/internal", + json={"app_id": self._app_id, "app_secret": self._app_secret}, ) - payload = self._check_response(resp) - return str(payload.get("file_key") or "") + payload = self._check_response(response) + token = str(payload.get("tenant_access_token") or "").strip() + expire = int(payload.get("expire") or 0) + if not token or expire <= 0: + raise RuntimeError("飞书 token response 缺少有效 token/expire") + self._token = _TokenCache(token, time.time() + expire) + return token - def _check_response(self, resp: httpx.Response) -> dict[str, Any]: - _ = resp.raise_for_status() - payload = cast(dict[str, Any], resp.json()) + def _check_response(self, response: httpx.Response) -> dict[str, Any]: + response.raise_for_status() + payload = response.json() + if not isinstance(payload, dict): + raise ValueError("Feishu response 必须是 object") code = int(payload.get("code") or 0) if code != 0: raise FeishuApiError(code, str(payload.get("msg") or "")) data = payload.get("data") - return cast(dict[str, Any], data) if isinstance(data, dict) else {} + return cast(dict[str, Any], data) if isinstance(data, dict) else payload + + # ------------------------------------------------------------------ + # Resource helpers + + def _read_credential(self, name: str) -> str: + if self._provider_client is None: + raise RuntimeError("Feishu provider client 尚未创建") + matches = [ + (path, ref) + for path, ref in self._credentials.items() + if path in _CREDENTIAL_ALIASES[name] + ] + if len(matches) != 1: + raise RuntimeError( + f"Feishu credential {name} 必须恰好有一个 physical alias" + ) + _, ref = matches[0] + value = self._provider_client.credential(ref) + if not isinstance(value, str) or not value: + raise RuntimeError(f"Feishu credential {name} 为空") + return value + + def _cleanup_failure(self, resource: str, error: BaseException) -> ChannelCleanupFailure: + return ChannelCleanupFailure( + stage="channel-stop", + plugin_id=_CHANNEL, + generation_id=self._context.generation_id, + binding_token=self._binding_token, + resource=resource, + error_type=type(error).__name__, + message=str(error) or type(error).__name__, + retry_action="retry_generation_cleanup", + ) - # 带频控退避重试的消息发送(最终回复 / 主动推送,不能丢)。对齐 Telegram 的 RetryAfter 处理。 - async def _with_rate_limit_retry( - self, - factory: Callable[[], Coroutine[Any, Any, dict[str, Any]]], - *, - label: str, - ) -> dict[str, Any]: - delay = _RETRY_BASE_DELAY_S - for attempt in range(1, _MESSAGE_MAX_ATTEMPTS + 1): + async def _close_resources_after_start_failure(self) -> None: + self._ws_stopped.set() + try: + await self._disconnect_ws() + except Exception: + logger.debug("[feishu] start failure websocket cleanup failed", exc_info=True) + thread = self._ws_thread + if thread is not None: + await asyncio.to_thread(thread.join, _WS_STOP_TIMEOUT_S) + self._remove_sdk_shutdown_filter() + if self._stream_subscription is not None: try: - return await factory() - except (httpx.HTTPStatusError, FeishuApiError) as e: - if attempt >= _MESSAGE_MAX_ATTEMPTS or not _is_rate_limited(e): - raise - wait = _retry_after_seconds(e, delay) - logger.warning( - "[feishu] %s 命中频控,退避重试 attempt=%d/%d delay=%.1fs", - label, - attempt, - _MESSAGE_MAX_ATTEMPTS, - wait, - ) - await asyncio.sleep(wait) - delay = min(delay * 2, _RETRY_MAX_DELAY_S) - raise RuntimeError(f"{label} 重试耗尽") + self._stream_subscription.close_admission() + await self._stream_subscription.await_quiescence() + await self._stream_subscription.close() + except Exception: + logger.debug("[feishu] start failure stream cleanup failed", exc_info=True) + if self._client is not None: + await self._client.aclose() + if self._provider_client is not None: + await self._provider_client.aclose() + self._client = None + self._provider_client = None + self._stream_subscription = None + self._ws_client = None + self._ws_loop = None + self._ws_thread = None + self._app_id = None + self._app_secret = None + self._token = None + + +def _domain(config: Mapping[str, object]) -> str: + value = config.get("domain", "https://open.feishu.cn") + if not isinstance(value, str) or not value.strip(): + return "https://open.feishu.cn" + return value.rstrip("/") + + +def _allow_from(config: Mapping[str, object]) -> frozenset[str]: + value = config.get("allow_from", ()) + if isinstance(value, str): + return frozenset({value}) if value.strip() else frozenset() + if not isinstance(value, (tuple, list)): + return frozenset() + return frozenset(item.strip() for item in value if isinstance(item, str) and item.strip()) + + +def _message_timestamp(message: Any) -> datetime: + raw = getattr(message, "create_time", None) + try: + seconds = float(raw) / 1000.0 if raw not in (None, "") else 0.0 + if seconds > 0: + return datetime.fromtimestamp(seconds, tz=timezone.utc) + except (TypeError, ValueError, OverflowError): + pass + return datetime.now(timezone.utc) - async def _get_access_token(self) -> str: - if self._token and self._token.expires_at > time.time() + 60: - return self._token.token - resp = await self._client.post( - f"{self._domain}/open-apis/auth/v3/tenant_access_token/internal", - json={"app_id": self._app_id, "app_secret": self._app_secret}, - ) - _ = resp.raise_for_status() - payload = cast(dict[str, Any], resp.json()) - code = int(payload.get("code") or 0) - if code != 0: - raise RuntimeError(f"飞书 token 获取失败 code={code} msg={payload.get('msg')}") - token = str(payload.get("tenant_access_token") or "") - expire = int(payload.get("expire") or 0) - self._token = _TokenCache(token=token, expires_at=time.time() + expire) - return token + +def _turn_id(event: TurnStreamEvent) -> str: + payload = event.payload + return cast(str, getattr(payload, "turn_id")) def _extract_text(content: str) -> str: @@ -913,10 +1004,9 @@ def _extract_text(content: str) -> str: return content.strip() if not isinstance(parsed, dict): return content.strip() - return str(cast(dict[str, object], parsed).get("text") or "").strip() + return str(parsed.get("text") or "").strip() -# 按行切分超长文本,单段不超过 limit(对齐 Telegram 的分块发送,避免超卡片大小上限)。 def _split_markdown(text: str, limit: int) -> list[str]: if len(text) <= limit: return [text] @@ -924,9 +1014,10 @@ def _split_markdown(text: str, limit: int) -> list[str]: current: list[str] = [] current_len = 0 for line in text.splitlines(keepends=True): - if current_len + len(line) > limit and current: + if current and current_len + len(line) > limit: chunks.append("".join(current)) - current, current_len = [], 0 + current = [] + current_len = 0 while len(line) > limit: chunks.append(line[:limit]) line = line[limit:] @@ -935,63 +1026,3 @@ def _split_markdown(text: str, limit: int) -> list[str]: if current: chunks.append("".join(current)) return chunks - - -def _extract_key(content: str, key: str) -> str: - try: - parsed = json.loads(content) - except json.JSONDecodeError: - return "" - if not isinstance(parsed, dict): - return "" - return str(cast(dict[str, object], parsed).get(key) or "").strip() - - -# 解析富文本 post:拼接文本段,收集内嵌图片 image_key。 -def _extract_post(content: str) -> tuple[str, list[str]]: - try: - parsed = json.loads(content) - except json.JSONDecodeError: - return content.strip(), [] - if not isinstance(parsed, dict): - return "", [] - body = cast(dict[str, Any], parsed) - if "content" not in body: - for value in body.values(): - if isinstance(value, dict) and "content" in value: - body = cast(dict[str, Any], value) - break - texts: list[str] = [] - images: list[str] = [] - title = str(body.get("title") or "").strip() - if title: - texts.append(title) - paragraphs = body.get("content") - if isinstance(paragraphs, list): - for paragraph in cast(list[Any], paragraphs): - line = _extract_post_line(paragraph, images) - if line: - texts.append(line) - return "\n".join(texts).strip(), images - - -def _extract_post_line(paragraph: Any, images: list[str]) -> str: - if not isinstance(paragraph, list): - return "" - parts: list[str] = [] - for segment in cast(list[Any], paragraph): - if not isinstance(segment, dict): - continue - seg = cast(dict[str, Any], segment) - tag = str(seg.get("tag") or "") - if tag == "text": - parts.append(str(seg.get("text") or "")) - elif tag in ("a", "link"): - parts.append(str(seg.get("text") or seg.get("href") or "")) - elif tag == "at": - parts.append("@" + str(seg.get("user_name") or seg.get("user_id") or "")) - elif tag == "img": - key = str(seg.get("image_key") or "") - if key: - images.append(key) - return "".join(parts) diff --git a/config.py b/config.py index 93fd337..c885c09 100644 --- a/config.py +++ b/config.py @@ -1,52 +1,29 @@ from __future__ import annotations -import re -from typing import cast +from typing import Annotated -from pydantic import AliasChoices, BaseModel, Field, field_validator +from pydantic import AliasChoices, BaseModel, ConfigDict, Field -_UNRESOLVED_ENV_RE = re.compile(r"^\$\{\w+\}$") -_DEFAULT_DOMAIN = "https://open.feishu.cn" +from agent.plugin_composition import CredentialRef -# 飞书插件配置来自插件数据目录下的 config.local.toml。 -class FeishuConfigModel(BaseModel): - app_id: str = Field( - default="", - validation_alias=AliasChoices("app_id", "appId"), - ) - app_secret: str = Field( - default="", - validation_alias=AliasChoices("app_secret", "appSecret"), - ) - allow_from: list[str] = Field( - default_factory=list, - validation_alias=AliasChoices("allow_from", "allowFrom"), - ) - domain: str = Field(default=_DEFAULT_DOMAIN) +class FeishuConfig(BaseModel): + """Validate Feishu's redacted Core config projection.""" - @field_validator("app_id", "app_secret", mode="before") - @classmethod - def _normalize_optional_text(cls, value: object) -> str: - text = str(value or "").strip() - if _UNRESOLVED_ENV_RE.fullmatch(text): - return "" - return text - - @field_validator("domain", mode="before") - @classmethod - def _normalize_domain(cls, value: object) -> str: - text = str(value or "").strip() - return (text or _DEFAULT_DOMAIN).rstrip("/") + model_config = ConfigDict( + arbitrary_types_allowed=True, + extra="ignore", + validate_by_alias=True, + validate_by_name=False, + ) - @field_validator("allow_from", mode="before") - @classmethod - def _normalize_allow_from(cls, value: object) -> list[str]: - if not isinstance(value, list): - return [] - result: list[str] = [] - for item in cast(list[object], value): - text = str(item).strip() - if text: - result.append(text) - return result + app_id: Annotated[ + CredentialRef | None, + Field(validation_alias=AliasChoices("appId", "app_id")), + ] = None + app_secret: Annotated[ + CredentialRef | None, + Field(validation_alias=AliasChoices("appSecret", "app_secret")), + ] = None + allow_from: tuple[str, ...] = () + domain: str = "https://open.feishu.cn" diff --git a/plugin.py b/plugin.py index 77e794d..5aaac47 100644 --- a/plugin.py +++ b/plugin.py @@ -1,31 +1,59 @@ from __future__ import annotations -from typing import TYPE_CHECKING, cast - -from agent.plugins import Plugin -from .channel import FeishuChannel -from .config import FeishuConfigModel - -if TYPE_CHECKING: - from infra.channels.contract import Channel - - -class FeishuPlugin(Plugin): - api_version = 2 - name = "feishu" - version = "1.0.0" - desc = "飞书私聊渠道" - ConfigModel = FeishuConfigModel - - def channels(self) -> list["Channel"]: - config = cast(FeishuConfigModel | None, self.context.config) - if config is None or not config.app_id or not config.app_secret: - return [] - return [ - FeishuChannel( - app_id=config.app_id, - app_secret=config.app_secret, - allow_from=config.allow_from, - domain=config.domain, - ) - ] +from agent.plugin_composition import ( + CHANNELS, + ChannelCapability, + ChannelDefinition, + Context, + InboundIdentity, + PluginChannels, +) + +from .channel import FeishuAdapter, build_feishu_channel +from .config import FeishuConfig + + +api_version = 3 +name = "feishu" +version = "3.0.0" +desc = "飞书私聊 v3 channel adapter" +author = "Akashic" +inject = (CHANNELS,) +Config = FeishuConfig + + +async def apply(ctx: Context, config: FeishuConfig) -> None: + """Register the immutable Feishu channel definition in the exact Root.""" + + channels: PluginChannels = ctx.require(CHANNELS) + await channels.register( + ctx, + ChannelDefinition( + name="feishu", + capabilities=frozenset( + { + ChannelCapability.INBOUND, + ChannelCapability.OUTBOUND, + ChannelCapability.CONTROL, + ChannelCapability.TURN_STREAM, + } + ), + factory_export="build_feishu_channel", + inbound_identity=InboundIdentity.PROVIDER_MESSAGE_ID, + credential_paths=("appId", "appSecret", "app_id", "app_secret"), + ), + ) + + +__all__ = [ + "Config", + "FeishuAdapter", + "api_version", + "apply", + "author", + "build_feishu_channel", + "desc", + "inject", + "name", + "version", +] diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..658d202 --- /dev/null +++ b/requirements.txt @@ -0,0 +1 @@ +lark-oapi>=1.4.0,<2 diff --git a/tests/test_manager_integration.py b/tests/test_manager_integration.py new file mode 100644 index 0000000..6232088 --- /dev/null +++ b/tests/test_manager_integration.py @@ -0,0 +1,148 @@ +from __future__ import annotations + +import shutil +import sys +from pathlib import Path +from typing import Any, cast + +import pytest + +from agent.plugins import channel_generation_host +from agent.plugins.manager import PluginManager +from bus.event_bus import EventBus + + +ROOT = Path(__file__).parents[1] + + +class FakeProviderClient: + def __init__(self) -> None: + self.closed = 0 + + def credential(self, ref) -> str: + if ref.path == ("appId",): + return "formal-app-id" + if ref.path == ("appSecret",): + return "formal-app-secret" + raise KeyError(ref.path) + + async def aclose(self) -> None: + self.closed += 1 + + +class FakeProviderFactory: + def __init__(self) -> None: + self.client = FakeProviderClient() + self.create_calls = 0 + self.close_calls = 0 + + async def create(self, credentials): + self.create_calls += 1 + return self.client + + async def aclose(self) -> None: + self.close_calls += 1 + + +def _stage(tmp_path: Path) -> tuple[Path, Path]: + plugin_root = tmp_path / "plugins" / "feishu" + plugin_root.mkdir(parents=True) + for filename in ( + "plugin.py", + "channel.py", + "config.py", + "cards.py", + "akashic.plugin.toml", + "requirements.txt", + ): + shutil.copy2(ROOT / filename, plugin_root / filename) + # Core's static-manifest admission requires the install-owned runtime + # marker. The test keeps the dependency install out of the manager gate + # and points that marker at this already prepared test interpreter. + runtime_python = plugin_root / ".venv" / "bin" / "python" + runtime_python.parent.mkdir(parents=True) + runtime_python.symlink_to(sys.executable) + workspace = tmp_path / "workspace" + data_dir = workspace / "plugin-data" / "feishu-builtin" + data_dir.mkdir(parents=True) + (data_dir / "config.local.toml").write_text( + 'appId = "formal-app-id"\nappSecret = "formal-app-secret"\n', + encoding="utf-8", + ) + return plugin_root, workspace + + +@pytest.mark.asyncio +async def test_manager_formal_candidate_discard_promote_and_cleanup( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Exercise the real Manager/Host path with a fake provider and no network.""" + + plugin_root, workspace = _stage(tmp_path) + factory = FakeProviderFactory() + original_resolver = channel_generation_host._resolve_sync_factory + + def resolve_factory(module, export): + factory_callable = original_resolver(module, export) + + def wrapped(context): + adapter = cast(Any, factory_callable(context)) + # Keep the real adapter.start/stop and replace only the provider socket loop. + adapter._run_ws_client = lambda: adapter._ws_stopped.wait() + return adapter + + return wrapped + + monkeypatch.setattr( + channel_generation_host, + "_resolve_sync_factory", + resolve_factory, + ) + manager = PluginManager( + plugin_dirs=[plugin_root.parent], + event_bus=EventBus(), + tool_registry=None, + workspace=workspace, + installed_cache_root=tmp_path / "home" / "cache", + ) + manager.bind_channel_provider_factory_resolver(lambda snapshot: {"feishu": factory}) + + await manager.load_all() + stable = manager.current_snapshot + runtime = manager.active_channel_generation + assert stable is not None and stable.state == "committed" + assert runtime is not None and runtime.channel("feishu").admission_open + assert factory.create_calls == 1 + assert factory.client.closed == 0 + + candidate = await manager.prepare_candidate("feishu") + assert candidate is not None and candidate.runtime_snapshot is not None + assert manager.current_snapshot is stable + assert factory.create_calls == 1 # candidate never calls the formal factory + assert candidate.validation_workspace is not None + validation_root = candidate.validation_workspace.parent + for path in validation_root.rglob("*"): + if path.is_file() and not path.is_symlink(): + assert b"formal-app-secret" not in path.read_bytes() + config_path = workspace / "plugin-data" / "feishu-builtin" / "config.local.toml" + assert config_path.read_text(encoding="utf-8") == ( + 'appId = "formal-app-id"\nappSecret = "formal-app-secret"\n' + ) + await manager.discard_prepared("feishu") + assert manager.current_snapshot is stable + assert factory.create_calls == 1 + + candidate = await manager.prepare_candidate("feishu") + assert candidate is not None + publication = await manager.publish_prepared("feishu") + assert publication["publication_state"] == "committed" + assert manager.current_snapshot is not stable + assert factory.create_calls == 2 + assert manager.active_channel_generation is not None + assert manager.active_channel_generation.channel("feishu").admission_open + + await manager.terminate_all() + assert manager.active_channel_generation is None + assert factory.close_calls == 2 + assert factory.client.closed == 2 diff --git a/tests/test_plugin.py b/tests/test_plugin.py index 97ed38a..1076400 100644 --- a/tests/test_plugin.py +++ b/tests/test_plugin.py @@ -2,32 +2,44 @@ import asyncio import importlib.util -import logging import sys -import threading from pathlib import Path from types import SimpleNamespace import pytest -from agent.tools.message_push import MessagePushTool -from bus.events import ( - AttachmentKind, - ChannelAttachment, - ChannelMessage, +from agent.plugin_composition.channels import ( + ChannelDeliveryReceipt, + ChannelFactoryContext, + ChannelInboundMessage, + ChannelPresentationPorts, + ControlReceipt, + CredentialRef, DeliveryStatus, + PresentationReceipt, + ProviderDeliveryReceipt, + ProviderDeliveryRequest, + RawInbound, + StreamDeltaPresentation, + ToolPresentation, + TurnOutputCompletedPresentation, + TurnStartedPresentation, + TurnStreamEvent, + TurnStreamEventKind, ) +ROOT = Path(__file__).parents[1] + + def _load_plugin_module(): - path = Path(__file__).parents[1] / "plugin.py" spec = importlib.util.spec_from_file_location( - "test_feishu_plugin", - path, - submodule_search_locations=[str(path.parent)], + "feishu_v3_test_plugin", + ROOT / "plugin.py", + submodule_search_locations=[str(ROOT)], ) if spec is None or spec.loader is None: - raise ImportError(str(path)) + raise ImportError("unable to load Feishu plugin") module = importlib.util.module_from_spec(spec) sys.modules[spec.name] = module spec.loader.exec_module(module) @@ -35,208 +47,450 @@ def _load_plugin_module(): module = _load_plugin_module() -FeishuConfigModel = module.FeishuConfigModel -FeishuPlugin = module.FeishuPlugin -SdkShutdownLogFilter = sys.modules[FeishuPlugin.__module__.removesuffix(".plugin") + ".channel"]._SdkShutdownLogFilter -def test_feishu_plugin_without_config_returns_no_channels() -> None: - plugin = FeishuPlugin() - plugin.context = type("Ctx", (), {"config": None})() - assert plugin.channels() == [] +class FakeProviderClient: + def __init__(self) -> None: + self.closed = False + self.requested: list[tuple[str, ...]] = [] + def credential(self, ref: CredentialRef) -> str: + self.requested.append(ref.path) + if ref.path == ("appId",): + return "app" + if ref.path == ("appSecret",): + return "secret" + raise KeyError(ref.path) -def test_feishu_plugin_with_config_returns_channel() -> None: - plugin = FeishuPlugin() - plugin.context = type( - "Ctx", - (), - { - "config": FeishuConfigModel( - app_id="app", - app_secret="secret", - allow_from=[], - domain="https://open.feishu.cn", - ) + async def aclose(self) -> None: + self.closed = True + + +class FakeProviderFactory: + def __init__(self) -> None: + self.client = FakeProviderClient() + self.create_calls = 0 + self.received: dict[str, CredentialRef] | None = None + self.closed = False + + async def create(self, credentials): + self.create_calls += 1 + self.received = dict(credentials) + return self.client + + async def aclose(self) -> None: + self.closed = True + + +class FakeIngress: + def __init__(self, accepted: bool = True) -> None: + self.accepted = accepted + self.raw: list[RawInbound] = [] + + async def admit(self, raw: RawInbound) -> bool: + self.raw.append(raw) + return self.accepted + + +class FakeIdentity: + def __init__(self, values: dict[str, str] | None = None) -> None: + self.values = values or {} + self.lookups: list[str] = [] + + def resolve(self, provider_identity: str) -> str | None: + self.lookups.append(provider_identity) + return self.values.get(provider_identity) + + +class FakeControl: + def __init__(self) -> None: + self.raw: RawInbound | None = None + self.bodies = None + + async def interrupt(self, raw: RawInbound, *, response_bodies) -> ControlReceipt: + self.raw = raw + self.bodies = response_bodies + return ControlReceipt( + accepted=True, + reason="interrupted", + response=ChannelDeliveryReceipt("control-delivery", DeliveryStatus.DELIVERED), + ) + + +class FakeSubscription: + def __init__(self, callback) -> None: + self.callback = callback + self.admission_closed = False + self.closed = False + + def close_admission(self) -> None: + self.admission_closed = True + + async def await_quiescence(self) -> None: + return None + + async def close(self) -> None: + self.closed = True + + +class FakeTurnStream: + def __init__(self) -> None: + self.subscription: FakeSubscription | None = None + + def subscribe(self, callback) -> FakeSubscription: + self.subscription = FakeSubscription(callback) + return self.subscription + + +def _context( + *, + factory: FakeProviderFactory | None = None, + ingress: FakeIngress | None = None, + identity: FakeIdentity | None = None, + control: FakeControl | None = None, + stream: FakeTurnStream | None = None, +) -> ChannelFactoryContext: + return ChannelFactoryContext( + snapshot_id="snapshot-1", + generation_id="generation-1", + binding_token="binding-1", + config={"allow_from": ("ou_sender",), "domain": "https://example.test"}, + credentials={ + "appId": CredentialRef(("appId",)), + "appSecret": CredentialRef(("appSecret",)), }, - )() - assert len(plugin.channels()) == 1 + provider_client_factory=factory or FakeProviderFactory(), + ingress=ingress or FakeIngress(), + identity=identity or FakeIdentity(), + control=control or FakeControl(), + turn_stream=stream or FakeTurnStream(), + ) -def test_sdk_shutdown_filter_only_hides_errors_after_stop() -> None: - stopped = threading.Event() - log_filter = SdkShutdownLogFilter(stopped) - record = logging.LogRecord( - "Lark", - logging.ERROR, - __file__, - 1, - "receive message loop exit, err: closed", - (), - None, +def _message(*, message_type: str = "text", content: str = '{"text":"hello"}'): + return SimpleNamespace( + chat_type="p2p", + message_id="msg-1", + chat_id="oc_chat", + message_type=message_type, + content=content, + parent_id="", + create_time="1700000000000", + ) + + +def test_plugin_is_pure_v3_and_declares_exact_feishu_channel() -> None: + from agent.plugins.composable import ComposablePlugin + from agent.plugins.static_manifest import load_static_plugin_manifest + + instance = ComposablePlugin.from_module(module) + assert instance.api_version == 3 + assert not hasattr(module, "FeishuPlugin") + manifest = load_static_plugin_manifest(ROOT) + assert manifest.api_version == 3 + assert manifest.channel_credentials == ( + ("feishu", ("appId", "appSecret", "app_id", "app_secret")), + ) + + +def test_config_accepts_only_opaque_credential_refs() -> None: + from pydantic import ValidationError + + config = module.Config.model_validate( + { + "appId": CredentialRef(("appId",)), + "appSecret": CredentialRef(("appSecret",)), + } ) + assert config.app_id == CredentialRef(("appId",)) + with pytest.raises(ValidationError): + module.Config.model_validate({"appId": "secret"}) - assert log_filter.filter(record) - stopped.set() - assert not log_filter.filter(record) + +@pytest.mark.asyncio +async def test_apply_registers_definition_through_exact_root_service() -> None: + calls = [] + + class Channels: + async def register(self, ctx, definition) -> None: + calls.append((ctx, definition)) + + class Context: + runtime = SimpleNamespace(config=module.Config()) + + def require(self, key): + assert key.name == "core.channels" + return Channels() + + await module.apply(Context(), module.Config()) + definition = calls[0][1] + assert definition.name == "feishu" + assert {item.value for item in definition.capabilities} == { + "inbound", + "outbound", + "control", + "turn_stream", + } + assert definition.factory_export == "build_feishu_channel" + assert definition.inbound_identity.value == "provider_message_id" + + +def test_candidate_factory_does_not_create_client_or_resolve_credentials() -> None: + factory = FakeProviderFactory() + adapter = module.build_feishu_channel(_context(factory=factory)) + assert factory.create_calls == 0 + assert getattr(adapter, "_client") is None + assert getattr(adapter, "_provider_client") is None + assert getattr(adapter, "_app_secret") is None @pytest.mark.asyncio -async def test_inbound_future_is_cancelled_during_stop() -> None: - plugin = FeishuPlugin() - plugin.context = type( - "Ctx", - (), - {"config": FeishuConfigModel(app_id="app", app_secret="secret")}, - )() - channel = plugin.channels()[0] - channel._loop = asyncio.get_running_loop() - channel._ws_stopped.clear() - started = asyncio.Event() - cancelled = asyncio.Event() - - async def handle(_event: object) -> None: - started.set() - try: - await asyncio.Event().wait() - finally: - cancelled.set() - - channel._handle_message_event = handle - channel._on_sdk_message(object()) - await started.wait() - channel._ws_stopped.set() - await channel._drain_inbound_tasks() - - assert cancelled.is_set() - assert channel._inbound_tasks == set() +async def test_formal_start_deliver_and_stop_use_controlled_provider_client() -> None: + factory = FakeProviderFactory() + stream = FakeTurnStream() + adapter = module.build_feishu_channel(_context(factory=factory, stream=stream)) + adapter._run_ws_client = lambda: adapter._ws_stopped.wait() + adapter.attach_presentation( + ChannelPresentationPorts(control=FakeControl(), turn_stream=stream) + ) + ready = await adapter.start() + assert ready.binding_token == "binding-1" + assert not ready.admission_open + assert factory.create_calls == 1 + assert factory.received == { + "appId": CredentialRef(("appId",)), + "appSecret": CredentialRef(("appSecret",)), + } + + calls: list[tuple[str, str, str]] = [] + + async def post(recipient: str, message_type: str, content: str): + calls.append((recipient, message_type, content)) + return {"message_id": f"provider-{len(calls)}"} + + adapter._post_message_once = post + receipt = await adapter.deliver( + ProviderDeliveryRequest( + binding_token="binding-1", + delivery_id="delivery-1", + recipient="oc_chat", + body="hello", + ) + ) + assert receipt.status is DeliveryStatus.DELIVERED + assert receipt.provider_ids == ("provider-1",) + assert calls[0][1] == "interactive" + + stop = await adapter.stop() + assert stop.resources_closed + assert factory.client.closed + assert stream.subscription is not None and stream.subscription.closed @pytest.mark.asyncio -async def test_channel_can_start_stop_twice( - monkeypatch: pytest.MonkeyPatch, -) -> None: - plugin = FeishuPlugin() - plugin.context = type( - "Ctx", - (), - {"config": FeishuConfigModel(app_id="app", app_secret="secret")}, - )() - channel = plugin.channels()[0] - channel_module = sys.modules[type(channel).__module__] - starts = 0 - - class IdentityIndex: - def __init__(self, *_args, **_kwargs) -> None: - return None - - def rebuild(self) -> int: - return 0 - - def run_ws_client() -> None: - nonlocal starts - starts += 1 - channel._ws_stopped.wait() - - monkeypatch.setattr(channel_module, "SessionIdentityIndex", IdentityIndex) - channel._run_ws_client = run_ws_client - registry = SimpleNamespace( - on=lambda *_args: object(), - subscribe_outbound=lambda *_args: object(), - ) - push_tools = [MessagePushTool(), MessagePushTool()] - context = SimpleNamespace( - bus=registry, - event_bus=registry, - push_tool=push_tools[0], - interrupt_controller=None, - attachment_store=None, - session_manager=None, - ) - - await channel.start(context) - await channel.stop() - context.push_tool = push_tools[1] - await channel.start(context) - await channel.stop() - - assert starts == 2 - assert channel._ws_thread is None - assert all("feishu" in tool._adapters for tool in push_tools) +async def test_stop_failure_retains_provider_owner_for_exact_retry() -> None: + class FlakyClient(FakeProviderClient): + def __init__(self) -> None: + super().__init__() + self.attempts = 0 + + async def aclose(self) -> None: + self.attempts += 1 + if self.attempts == 1: + raise RuntimeError("provider close interrupted") + await super().aclose() + + class FlakyFactory(FakeProviderFactory): + def __init__(self) -> None: + super().__init__() + self.client = FlakyClient() + + factory = FlakyFactory() + stream = FakeTurnStream() + adapter = module.build_feishu_channel(_context(factory=factory, stream=stream)) + adapter._run_ws_client = lambda: adapter._ws_stopped.wait() + adapter.attach_presentation( + ChannelPresentationPorts(control=FakeControl(), turn_stream=stream) + ) + await adapter.start() + first = await adapter.stop() + assert not first.resources_closed + assert any(item.resource == "provider-client" for item in first.failures) + second = await adapter.stop() + assert second.resources_closed + assert factory.client.attempts == 2 @pytest.mark.asyncio -async def test_delivery_adapter_submits_complete_message() -> None: - plugin = FeishuPlugin() - plugin.context = type( - "Ctx", - (), - {"config": FeishuConfigModel(app_id="app", app_secret="secret")}, - )() - channel = plugin.channels()[0] - calls: list[tuple[object, ...]] = [] - - async def send_text(chat_id: str, content: str) -> None: - calls.append(("text", chat_id, content)) - - async def send_file( - chat_id: str, - path: str, - name: str | None = None, - caption: str | None = None, - ) -> None: - calls.append(("file", chat_id, path, name, caption)) - - async def send_image(chat_id: str, path: str) -> None: - calls.append(("image", chat_id, path)) - - channel.send = send_text - channel.send_file = send_file - channel.send_image = send_image - receipt = await channel._deliver_message( - ChannelMessage( - channel="feishu", - chat_id="ou_1", - content="正文", - attachments=( - ChannelAttachment(AttachmentKind.FILE, "/tmp/a.txt", "a.txt"), - ChannelAttachment(AttachmentKind.IMAGE, "/tmp/a.png"), - ), +async def test_attachment_delivery_is_deterministic_rejected_without_provider_effect() -> None: + factory = FakeProviderFactory() + stream = FakeTurnStream() + adapter = module.build_feishu_channel(_context(factory=factory, stream=stream)) + adapter.attach_presentation( + ChannelPresentationPorts(control=FakeControl(), turn_stream=stream) + ) + from agent.plugin_composition.channels import AttachmentKind, AttachmentRef + + attachment = AttachmentRef( + artifact_id="artifact-1", + kind=AttachmentKind.FILE, + filename="a.txt", + media_type="text/plain", + size_bytes=1, + sha256="0" * 64, + ) + receipt = await adapter.deliver( + ProviderDeliveryRequest( + binding_token="binding-1", + delivery_id="delivery-attachment", + recipient="oc_chat", + body="body", + attachments=(attachment,), ) ) + assert receipt.status is DeliveryStatus.REJECTED + assert factory.create_calls == 0 - assert receipt.status is DeliveryStatus.SUCCESS - assert calls == [ - ("text", "ou_1", "正文"), - ("file", "ou_1", "/tmp/a.txt", "a.txt", None), - ("image", "ou_1", "/tmp/a.png"), - ] + +@pytest.mark.asyncio +async def test_delivery_fallback_only_runs_after_deterministic_card_rejection() -> None: + factory = FakeProviderFactory() + stream = FakeTurnStream() + adapter = module.build_feishu_channel(_context(factory=factory, stream=stream)) + adapter._run_ws_client = lambda: adapter._ws_stopped.wait() + adapter.attach_presentation( + ChannelPresentationPorts(control=FakeControl(), turn_stream=stream) + ) + await adapter.start() + calls: list[str] = [] + + async def rejected_card(recipient: str, message_type: str, content: str): + calls.append(message_type) + if message_type == "interactive": + raise module.channel.FeishuApiError(123, "card rejected") + return {"message_id": "text-fallback"} + + adapter._post_message_once = rejected_card + fallback = await adapter.deliver( + ProviderDeliveryRequest("binding-1", "delivery-fallback", "oc_chat", "hello") + ) + assert fallback.status is DeliveryStatus.DELIVERED + assert calls == ["interactive", "text"] + + calls.clear() + + async def uncertain(recipient: str, message_type: str, content: str): + calls.append(message_type) + raise TimeoutError("provider effect unknown") + + adapter._post_message_once = uncertain + unknown = await adapter.deliver( + ProviderDeliveryRequest("binding-1", "delivery-unknown", "oc_chat", "hello") + ) + assert unknown.status is DeliveryStatus.UNKNOWN + assert calls == ["interactive"] + await adapter.stop() @pytest.mark.asyncio -async def test_disconnect_stops_sdk_event_loop() -> None: - plugin = FeishuPlugin() - plugin.context = type( - "Ctx", - (), - {"config": FeishuConfigModel(app_id="app", app_secret="secret")}, - )() - channel = plugin.channels()[0] - ws_loop = asyncio.new_event_loop() - disconnected = threading.Event() - - class _WsClient: - async def _disconnect(self) -> None: - disconnected.set() - - thread = threading.Thread(target=ws_loop.run_forever) - thread.start() - channel._ws_client = _WsClient() - channel._ws_loop = ws_loop - - await channel._disconnect_ws() - await asyncio.to_thread(thread.join, 2) - ws_loop.close() - - assert disconnected.is_set() - assert not thread.is_alive() +async def test_text_inbound_admits_raw_message_and_attachment_is_rejected() -> None: + ingress = FakeIngress() + stream = FakeTurnStream() + adapter = module.build_feishu_channel(_context(ingress=ingress, stream=stream)) + adapter.attach_presentation( + ChannelPresentationPorts(control=FakeControl(), turn_stream=stream) + ) + status = await adapter._ingest_message( + _message(), "msg-1", "oc_chat", "ou_sender", "", "" + ) + assert status is DeliveryStatus.DELIVERED + assert ingress.raw[0].provider_identity == "ou_sender" + assert ingress.raw[0].recipient == "oc_chat" + assert ingress.raw[0].message.content == "hello" + rejected = await adapter._ingest_message( + _message(message_type="image", content='{"image_key":"img"}'), + "msg-2", + "oc_chat", + "ou_sender", + "", + "", + ) + assert rejected is DeliveryStatus.REJECTED + assert len(ingress.raw) == 1 + + +@pytest.mark.asyncio +async def test_stop_uses_exact_core_control_port() -> None: + control = FakeControl() + adapter = module.build_feishu_channel(_context(control=control)) + stream = FakeTurnStream() + adapter.attach_presentation( + ChannelPresentationPorts(control=control, turn_stream=stream) + ) + status = await adapter._ingest_message( + _message(content='{"text":"/stop"}'), + "stop-1", + "oc_chat", + "ou_sender", + "", + "", + ) + assert status is DeliveryStatus.DELIVERED + assert control.raw is not None + assert control.raw.message.content == "/stop" + + +@pytest.mark.asyncio +async def test_turn_stream_keeps_one_preview_id_and_final_summary_patch() -> None: + stream = FakeTurnStream() + adapter = module.build_feishu_channel(_context(stream=stream)) + adapter.attach_presentation( + ChannelPresentationPorts(control=FakeControl(), turn_stream=stream) + ) + calls: list[tuple[str, str, str]] = [] + + async def post(recipient: str, message_type: str, content: str): + calls.append(("post", recipient, content)) + return DeliveryStatus.DELIVERED, "preview-1", None + + async def patch(message_id: str, content: str): + calls.append(("patch", message_id, content)) + return DeliveryStatus.DELIVERED, None + + adapter._send_one = post # type: ignore[method-assign] + adapter._patch_one = patch # type: ignore[method-assign] + adapter._inbound_recipients["msg-1"] = "oc_chat" + + started = TurnStreamEvent( + "preview:turn-1", + TurnStreamEventKind.TURN_STARTED, + TurnStartedPresentation("turn-1", "msg-1"), + ) + delta = TurnStreamEvent( + "preview:turn-1", + TurnStreamEventKind.STREAM_DELTA, + StreamDeltaPresentation("turn-1", 1, "hello", ""), + ) + tool = TurnStreamEvent( + "preview:turn-1", + TurnStreamEventKind.TOOL_STARTED, + ToolPresentation("turn-1", 2, "tool-1", "shell"), + ) + completed = TurnStreamEvent( + "preview:turn-1", + TurnStreamEventKind.TURN_OUTPUT_COMPLETED, + TurnOutputCompletedPresentation("turn-1", 3), + ) + assert (await adapter._on_turn_stream(started)).status is DeliveryStatus.DELIVERED + assert (await adapter._on_turn_stream(delta)).status is DeliveryStatus.DELIVERED + assert (await adapter._on_turn_stream(tool)).status is DeliveryStatus.DELIVERED + assert (await adapter._on_turn_stream(completed)).status is DeliveryStatus.DELIVERED + assert [item[0] for item in calls] == ["post", "patch", "patch", "patch"] + assert all(item[1] in {"oc_chat", "preview-1"} for item in calls) + assert adapter._inbound_recipients == {} + assert adapter._turn_recipients == {} + assert adapter._presentation_client_messages == {} + assert adapter._reply_buffers == {} + assert adapter._thinking_buffers == {} + assert adapter._tool_lines == {} + assert adapter._preview_messages == {} From a4bad5bb01f9154446277a8080146322b58734fb Mon Sep 17 00:00:00 2001 From: huashen <2494946808@qq.com> Date: Mon, 17 Aug 2026 19:15:25 +0800 Subject: [PATCH 02/11] fix(feishu): close v3 channel security gaps --- .github/workflows/plugin-api-v3.yml | 2 +- channel.py | 33 +++++--- config.py | 7 +- tests/test_plugin.py | 112 +++++++++++++++++++++++++++- 4 files changed, 138 insertions(+), 16 deletions(-) diff --git a/.github/workflows/plugin-api-v3.yml b/.github/workflows/plugin-api-v3.yml index 14464df..cdea4c0 100644 --- a/.github/workflows/plugin-api-v3.yml +++ b/.github/workflows/plugin-api-v3.yml @@ -36,7 +36,7 @@ jobs: - uses: actions/checkout@v4 with: repository: kachofugetsu09/akashic-agent - ref: 5e58d38d + ref: 20062a715d2c5822228b327863b51c8d036119b3 path: .akashic-core - uses: actions/setup-python@v5 with: diff --git a/channel.py b/channel.py index aceddf0..d312d9c 100644 --- a/channel.py +++ b/channel.py @@ -140,6 +140,7 @@ def __init__(self, context: ChannelFactoryContext) -> None: self._ws_client: Any | None = None self._ws_loop: asyncio.AbstractEventLoop | None = None self._ws_thread: threading.Thread | None = None + self._ws_thread_started = False self._ws_stopped = threading.Event() self._sdk_logger: logging.Logger | None = None self._sdk_shutdown_filter: logging.Filter | None = None @@ -175,8 +176,6 @@ async def start(self) -> ChannelReady: try: # 1. Only the formal Host invokes ProviderClientFactory and unwraps refs. self._provider_client = await self._provider_factory.create(self._credentials) - self._app_id = self._read_credential("app_id") - self._app_secret = self._read_credential("app_secret") self._client = httpx.AsyncClient(timeout=30.0) # 2. Subscribe through the exact Core stream and keep admission closed. @@ -190,7 +189,9 @@ async def start(self) -> ChannelReady: name="feishu-ws", daemon=True, ) + self._ws_thread_started = False self._ws_thread.start() + self._ws_thread_started = True self._started = True logger.info("[feishu] v3 channel started binding=%s", self._binding_token) return ChannelReady( @@ -289,14 +290,18 @@ async def stop(self) -> StopReceipt: except BaseException as error: failures.append(self._cleanup_failure("websocket-disconnect", error)) thread = self._ws_thread - if thread is not None: + if thread is not None and self._ws_thread_started: try: await asyncio.to_thread(thread.join, _WS_STOP_TIMEOUT_S) if thread.is_alive(): raise RuntimeError("飞书长连接线程停止超时") self._ws_thread = None + self._ws_thread_started = False except BaseException as error: failures.append(self._cleanup_failure("websocket-thread", error)) + elif thread is not None: + self._ws_thread = None + self._ws_thread_started = False self._remove_sdk_shutdown_filter() # 3. Complete in-process callback cleanup before returning the receipt. @@ -331,6 +336,7 @@ async def stop(self) -> StopReceipt: self._ws_client = None self._ws_loop = None self._ws_thread = None + self._ws_thread_started = False self._stream_subscription = None self._started = False self._stopping = False @@ -372,8 +378,8 @@ def _run_ws_client(self) -> None: def _build_ws_client(self) -> Any: """Create the Feishu SDK socket only after formal credential admission.""" - if not self._app_id or not self._app_secret: - raise RuntimeError("Feishu websocket 缺少 formal credentials") + app_id = self._read_credential("app_id") + app_secret = self._read_credential("app_secret") with warnings.catch_warnings(): warnings.filterwarnings( "ignore", @@ -396,8 +402,8 @@ def _build_ws_client(self) -> Any: .build() ) client = WsClient( - self._app_id, - self._app_secret, + app_id, + app_secret, log_level=LogLevel.INFO, event_handler=handler, domain=self._domain, @@ -472,7 +478,7 @@ async def _handle_message_event(self, event: Any) -> DeliveryStatus | None: user_id = str(getattr(sender_id, "user_id", "") or "").strip() union_id = str(getattr(sender_id, "union_id", "") or "").strip() identities = {open_id, user_id, union_id} - {""} - if self._allow_from and not identities.intersection(self._allow_from): + if not self._allow_from or not identities.intersection(self._allow_from): logger.warning("[feishu] 拒绝未授权私聊用户 open_id=%s", open_id) return DeliveryStatus.REJECTED chat_id = str(getattr(message, "chat_id", "") or "").strip() @@ -873,13 +879,15 @@ def _resolve_receive(self, recipient: str) -> tuple[str, str]: return value, "chat_id" async def _get_access_token(self) -> str: - if self._client is None or not self._app_id or not self._app_secret: + if self._client is None or self._provider_client is None: raise RuntimeError("Feishu formal credentials/client 未就绪") if self._token is not None and self._token.expires_at > time.time() + 60: return self._token.token + app_id = self._read_credential("app_id") + app_secret = self._read_credential("app_secret") response = await self._client.post( f"{self._domain}/open-apis/auth/v3/tenant_access_token/internal", - json={"app_id": self._app_id, "app_secret": self._app_secret}, + json={"app_id": app_id, "app_secret": app_secret}, ) payload = self._check_response(response) token = str(payload.get("tenant_access_token") or "").strip() @@ -940,8 +948,9 @@ async def _close_resources_after_start_failure(self) -> None: except Exception: logger.debug("[feishu] start failure websocket cleanup failed", exc_info=True) thread = self._ws_thread - if thread is not None: + if thread is not None and self._ws_thread_started: await asyncio.to_thread(thread.join, _WS_STOP_TIMEOUT_S) + self._ws_thread_started = False self._remove_sdk_shutdown_filter() if self._stream_subscription is not None: try: @@ -973,7 +982,7 @@ def _domain(config: Mapping[str, object]) -> str: def _allow_from(config: Mapping[str, object]) -> frozenset[str]: - value = config.get("allow_from", ()) + value = config.get("allow_from", config.get("allowFrom", ())) if isinstance(value, str): return frozenset({value}) if value.strip() else frozenset() if not isinstance(value, (tuple, list)): diff --git a/config.py b/config.py index c885c09..6d14573 100644 --- a/config.py +++ b/config.py @@ -12,7 +12,7 @@ class FeishuConfig(BaseModel): model_config = ConfigDict( arbitrary_types_allowed=True, - extra="ignore", + extra="forbid", validate_by_alias=True, validate_by_name=False, ) @@ -25,5 +25,8 @@ class FeishuConfig(BaseModel): CredentialRef | None, Field(validation_alias=AliasChoices("appSecret", "app_secret")), ] = None - allow_from: tuple[str, ...] = () + allow_from: Annotated[ + tuple[str, ...], + Field(validation_alias=AliasChoices("allow_from", "allowFrom")), + ] = () domain: str = "https://open.feishu.cn" diff --git a/tests/test_plugin.py b/tests/test_plugin.py index 1076400..d96f0f9 100644 --- a/tests/test_plugin.py +++ b/tests/test_plugin.py @@ -3,6 +3,7 @@ import asyncio import importlib.util import sys +import threading from pathlib import Path from types import SimpleNamespace @@ -149,12 +150,17 @@ def _context( identity: FakeIdentity | None = None, control: FakeControl | None = None, stream: FakeTurnStream | None = None, + config: dict[str, object] | None = None, ) -> ChannelFactoryContext: return ChannelFactoryContext( snapshot_id="snapshot-1", generation_id="generation-1", binding_token="binding-1", - config={"allow_from": ("ou_sender",), "domain": "https://example.test"}, + config=( + config + if config is not None + else {"allow_from": ("ou_sender",), "domain": "https://example.test"} + ), credentials={ "appId": CredentialRef(("appId",)), "appSecret": CredentialRef(("appSecret",)), @@ -179,6 +185,17 @@ def _message(*, message_type: str = "text", content: str = '{"text":"hello"}'): ) +def _event(*, content: str = '{"text":"hello"}', open_id: str = "ou_sender"): + return SimpleNamespace( + event=SimpleNamespace( + message=_message(content=content), + sender=SimpleNamespace( + sender_id=SimpleNamespace(open_id=open_id, user_id="", union_id="") + ), + ) + ) + + def test_plugin_is_pure_v3_and_declares_exact_feishu_channel() -> None: from agent.plugins.composable import ComposablePlugin from agent.plugins.static_manifest import load_static_plugin_manifest @@ -207,6 +224,21 @@ def test_config_accepts_only_opaque_credential_refs() -> None: module.Config.model_validate({"appId": "secret"}) +def test_config_accepts_legacy_allow_from_alias_and_forbids_unknown_keys() -> None: + from pydantic import ValidationError + + config = module.Config.model_validate( + { + "appId": CredentialRef(("appId",)), + "appSecret": CredentialRef(("appSecret",)), + "allowFrom": ["ou_sender"], + } + ) + assert config.allow_from == ("ou_sender",) + with pytest.raises(ValidationError): + module.Config.model_validate({"unknown": True}) + + @pytest.mark.asyncio async def test_apply_registers_definition_through_exact_root_service() -> None: calls = [] @@ -261,6 +293,8 @@ async def test_formal_start_deliver_and_stop_use_controlled_provider_client() -> "appId": CredentialRef(("appId",)), "appSecret": CredentialRef(("appSecret",)), } + assert adapter._app_id is None + assert adapter._app_secret is None calls: list[tuple[str, str, str]] = [] @@ -287,6 +321,33 @@ async def post(recipient: str, message_type: str, content: str): assert stream.subscription is not None and stream.subscription.closed +@pytest.mark.asyncio +async def test_start_failure_does_not_join_unstarted_websocket_thread( + monkeypatch: pytest.MonkeyPatch, +) -> None: + factory = FakeProviderFactory() + stream = FakeTurnStream() + adapter = module.build_feishu_channel(_context(factory=factory, stream=stream)) + adapter.attach_presentation( + ChannelPresentationPorts(control=FakeControl(), turn_stream=stream) + ) + + def fail_start(self: threading.Thread) -> None: + if self.name == "feishu-ws": + raise RuntimeError("thread start blocked") + raise AssertionError(f"unexpected thread: {self.name}") + + monkeypatch.setattr(threading.Thread, "start", fail_start) + with pytest.raises(RuntimeError, match="thread start blocked"): + await adapter.start() + + assert factory.client.closed + assert adapter._ws_thread is None + assert not adapter._ws_thread_started + stop = await adapter.stop() + assert stop.resources_closed + + @pytest.mark.asyncio async def test_stop_failure_retains_provider_owner_for_exact_retry() -> None: class FlakyClient(FakeProviderClient): @@ -419,6 +480,55 @@ async def test_text_inbound_admits_raw_message_and_attachment_is_rejected() -> N assert len(ingress.raw) == 1 +@pytest.mark.asyncio +async def test_unauthorized_inbound_and_control_are_fail_closed() -> None: + ingress = FakeIngress() + control = FakeControl() + stream = FakeTurnStream() + adapter = module.build_feishu_channel( + _context( + ingress=ingress, + control=control, + stream=stream, + config={"allowFrom": (), "domain": "https://example.test"}, + ) + ) + adapter.attach_presentation( + ChannelPresentationPorts(control=control, turn_stream=stream) + ) + + inbound = await adapter._handle_message_event(_event()) + control_attempt = await adapter._handle_message_event( + _event(content='{"text":"/stop"}') + ) + + assert inbound is DeliveryStatus.REJECTED + assert control_attempt is DeliveryStatus.REJECTED + assert ingress.raw == [] + assert control.raw is None + + +@pytest.mark.asyncio +async def test_legacy_allow_from_alias_reaches_inbound_allowlist() -> None: + ingress = FakeIngress() + stream = FakeTurnStream() + adapter = module.build_feishu_channel( + _context( + ingress=ingress, + stream=stream, + config={"allowFrom": ("ou_sender",), "domain": "https://example.test"}, + ) + ) + adapter.attach_presentation( + ChannelPresentationPorts(control=FakeControl(), turn_stream=stream) + ) + + status = await adapter._handle_message_event(_event()) + + assert status is DeliveryStatus.DELIVERED + assert len(ingress.raw) == 1 + + @pytest.mark.asyncio async def test_stop_uses_exact_core_control_port() -> None: control = FakeControl() From 571ea6c86afa843a8c1badaef096d2af7cecc136 Mon Sep 17 00:00:00 2001 From: huashen <2494946808@qq.com> Date: Mon, 17 Aug 2026 19:26:37 +0800 Subject: [PATCH 03/11] ci(plugin): pin retained channel binding core --- .github/workflows/plugin-api-v3.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/plugin-api-v3.yml b/.github/workflows/plugin-api-v3.yml index cdea4c0..798fc76 100644 --- a/.github/workflows/plugin-api-v3.yml +++ b/.github/workflows/plugin-api-v3.yml @@ -36,7 +36,7 @@ jobs: - uses: actions/checkout@v4 with: repository: kachofugetsu09/akashic-agent - ref: 20062a715d2c5822228b327863b51c8d036119b3 + ref: b97f919b1fd865d23d11095cbc63d2354803bad9 path: .akashic-core - uses: actions/setup-python@v5 with: From 732a3b09df825b3845f3cb710aed0b83eeff9273 Mon Sep 17 00:00:00 2001 From: huashen <2494946808@qq.com> Date: Mon, 17 Aug 2026 20:25:05 +0800 Subject: [PATCH 04/11] fix(feishu): validate credentials before channel start --- channel.py | 2 + tests/test_manager_integration.py | 66 +++++++++++++++++++++++++++++++ 2 files changed, 68 insertions(+) diff --git a/channel.py b/channel.py index d312d9c..2087668 100644 --- a/channel.py +++ b/channel.py @@ -176,6 +176,8 @@ async def start(self) -> ChannelReady: try: # 1. Only the formal Host invokes ProviderClientFactory and unwraps refs. self._provider_client = await self._provider_factory.create(self._credentials) + self._read_credential("app_id") + self._read_credential("app_secret") self._client = httpx.AsyncClient(timeout=30.0) # 2. Subscribe through the exact Core stream and keep admission closed. diff --git a/tests/test_manager_integration.py b/tests/test_manager_integration.py index 6232088..38d04a2 100644 --- a/tests/test_manager_integration.py +++ b/tests/test_manager_integration.py @@ -146,3 +146,69 @@ def wrapped(context): assert manager.active_channel_generation is None assert factory.close_calls == 2 assert factory.client.closed == 2 + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("config_text", "missing_credential"), + ( + ('appSecret = "formal-app-secret"\n', "app_id"), + ('appId = "formal-app-id"\n', "app_secret"), + ), +) +async def test_formal_start_rejects_missing_credential_before_binding( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + config_text: str, + missing_credential: str, +) -> None: + """Reject an incomplete formal credential pair before any channel resource starts.""" + + plugin_root, workspace = _stage(tmp_path) + config_path = workspace / "plugin-data" / "feishu-builtin" / "config.local.toml" + config_path.write_text(config_text, encoding="utf-8") + factory = FakeProviderFactory() + adapters: list[Any] = [] + original_resolver = channel_generation_host._resolve_sync_factory + + def resolve_factory(module, export): + factory_callable = original_resolver(module, export) + + def wrapped(context): + adapter = cast(Any, factory_callable(context)) + adapter._run_ws_client = lambda: adapter._ws_stopped.wait() + adapters.append(adapter) + return adapter + + return wrapped + + monkeypatch.setattr( + channel_generation_host, + "_resolve_sync_factory", + resolve_factory, + ) + manager = PluginManager( + plugin_dirs=[plugin_root.parent], + event_bus=EventBus(), + tool_registry=None, + workspace=workspace, + installed_cache_root=tmp_path / "home" / "cache", + ) + manager.bind_channel_provider_factory_resolver(lambda snapshot: {"feishu": factory}) + + with pytest.raises(RuntimeError, match=missing_credential): + await manager.load_all() + + assert len(adapters) == 1 + adapter = adapters[0] + assert factory.create_calls == 1 + assert factory.client.closed + assert adapter._provider_client is None + assert adapter._client is None + assert adapter._stream_subscription is None + assert adapter._ws_client is None + assert adapter._ws_loop is None + assert adapter._ws_thread is None + assert not adapter._ws_thread_started + assert not adapter._started + assert manager.active_channel_generation is None From 17751a79159a21867d1a9ea9ca7fd6dcd01bee0a Mon Sep 17 00:00:00 2001 From: huashen <2494946808@qq.com> Date: Mon, 17 Aug 2026 20:56:21 +0800 Subject: [PATCH 05/11] =?UTF-8?q?fix(feishu):=20=E4=BF=9D=E7=95=99?= =?UTF-8?q?=E5=90=AF=E5=8A=A8=E5=A4=B1=E8=B4=A5=E7=9A=84=E6=B5=81=E8=AE=A2?= =?UTF-8?q?=E9=98=85=E6=89=80=E6=9C=89=E6=9D=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- channel.py | 82 ++++++++++++++++++++++++++++++------------ tests/test_plugin.py | 86 ++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 142 insertions(+), 26 deletions(-) diff --git a/channel.py b/channel.py index 2087668..b47d153 100644 --- a/channel.py +++ b/channel.py @@ -201,8 +201,16 @@ async def start(self) -> ChannelReady: subscriptions=("feishu.websocket", "feishu.turn_stream"), admission_open=False, ) - except BaseException: - await self._close_resources_after_start_failure() + except BaseException as error: + cleanup_failures = await self._close_resources_after_start_failure() + if cleanup_failures: + error.add_note( + "Feishu start cleanup failed: " + + "; ".join( + str(failure) or type(failure).__name__ + for failure in cleanup_failures + ) + ) raise async def deliver(self, request: ProviderDeliveryRequest) -> ProviderDeliveryReceipt: @@ -943,37 +951,65 @@ def _cleanup_failure(self, resource: str, error: BaseException) -> ChannelCleanu retry_action="retry_generation_cleanup", ) - async def _close_resources_after_start_failure(self) -> None: + async def _close_resources_after_start_failure(self) -> tuple[BaseException, ...]: + """Release start-owned resources while retaining every failed owner for retry.""" + + failures: list[BaseException] = [] self._ws_stopped.set() try: await self._disconnect_ws() - except Exception: - logger.debug("[feishu] start failure websocket cleanup failed", exc_info=True) + except BaseException as error: + failures.append(error) + else: + self._ws_client = None + self._ws_loop = None thread = self._ws_thread if thread is not None and self._ws_thread_started: - await asyncio.to_thread(thread.join, _WS_STOP_TIMEOUT_S) - self._ws_thread_started = False + try: + await asyncio.to_thread(thread.join, _WS_STOP_TIMEOUT_S) + if thread.is_alive(): + raise RuntimeError("飞书长连接线程停止超时") + except BaseException as error: + failures.append(error) + else: + self._ws_thread = None + self._ws_thread_started = False + elif thread is not None: + self._ws_thread = None + self._ws_thread_started = False self._remove_sdk_shutdown_filter() - if self._stream_subscription is not None: + subscription = self._stream_subscription + if subscription is not None: try: - self._stream_subscription.close_admission() - await self._stream_subscription.await_quiescence() - await self._stream_subscription.close() - except Exception: - logger.debug("[feishu] start failure stream cleanup failed", exc_info=True) - if self._client is not None: - await self._client.aclose() - if self._provider_client is not None: - await self._provider_client.aclose() - self._client = None - self._provider_client = None - self._stream_subscription = None - self._ws_client = None - self._ws_loop = None - self._ws_thread = None + subscription.close_admission() + await subscription.await_quiescence() + await subscription.close() + except BaseException as error: + failures.append(error) + else: + self._stream_subscription = None + client = self._client + if client is not None: + try: + await client.aclose() + except BaseException as error: + failures.append(error) + else: + self._client = None + provider_client = self._provider_client + if provider_client is not None: + try: + await provider_client.aclose() + except BaseException as error: + failures.append(error) + else: + self._provider_client = None + if failures: + self._stopping = True self._app_id = None self._app_secret = None self._token = None + return tuple(failures) def _domain(config: Mapping[str, object]) -> str: diff --git a/tests/test_plugin.py b/tests/test_plugin.py index d96f0f9..7f6c87a 100644 --- a/tests/test_plugin.py +++ b/tests/test_plugin.py @@ -119,8 +119,10 @@ async def interrupt(self, raw: RawInbound, *, response_bodies) -> ControlReceipt class FakeSubscription: - def __init__(self, callback) -> None: + def __init__(self, callback, *, fail_close_attempts: int = 0) -> None: self.callback = callback + self.fail_close_attempts = fail_close_attempts + self.close_calls = 0 self.admission_closed = False self.closed = False @@ -131,15 +133,22 @@ async def await_quiescence(self) -> None: return None async def close(self) -> None: + self.close_calls += 1 + if self.close_calls <= self.fail_close_attempts: + raise RuntimeError("stream close interrupted") self.closed = True class FakeTurnStream: - def __init__(self) -> None: + def __init__(self, *, fail_close_attempts: int = 0) -> None: + self.fail_close_attempts = fail_close_attempts self.subscription: FakeSubscription | None = None def subscribe(self, callback) -> FakeSubscription: - self.subscription = FakeSubscription(callback) + self.subscription = FakeSubscription( + callback, + fail_close_attempts=self.fail_close_attempts, + ) return self.subscription @@ -348,6 +357,77 @@ def fail_start(self: threading.Thread) -> None: assert stop.resources_closed +@pytest.mark.asyncio +async def test_start_failure_retains_stream_owner_for_later_close_retry( + monkeypatch: pytest.MonkeyPatch, +) -> None: + factory = FakeProviderFactory() + stream = FakeTurnStream(fail_close_attempts=1) + adapter = module.build_feishu_channel(_context(factory=factory, stream=stream)) + adapter.attach_presentation( + ChannelPresentationPorts(control=FakeControl(), turn_stream=stream) + ) + + def fail_start(self: threading.Thread) -> None: + if self.name == "feishu-ws": + raise RuntimeError("thread start blocked") + raise AssertionError(f"unexpected thread: {self.name}") + + monkeypatch.setattr(threading.Thread, "start", fail_start) + with pytest.raises(RuntimeError, match="thread start blocked") as raised: + await adapter.start() + + subscription = stream.subscription + assert subscription is not None + assert subscription.close_calls == 1 + assert not subscription.closed + assert adapter._stream_subscription is subscription + assert any("stream close interrupted" in note for note in raised.value.__notes__) + + receipt = await adapter.stop() + assert receipt.resources_closed + assert receipt.failures == () + assert subscription.close_calls == 2 + assert subscription.closed + assert adapter._stream_subscription is None + + +@pytest.mark.asyncio +async def test_persistent_start_cleanup_failure_keeps_stream_owner_and_reports_retry( + monkeypatch: pytest.MonkeyPatch, +) -> None: + factory = FakeProviderFactory() + stream = FakeTurnStream(fail_close_attempts=2) + adapter = module.build_feishu_channel(_context(factory=factory, stream=stream)) + adapter.attach_presentation( + ChannelPresentationPorts(control=FakeControl(), turn_stream=stream) + ) + + def fail_start(self: threading.Thread) -> None: + if self.name == "feishu-ws": + raise RuntimeError("thread start blocked") + raise AssertionError(f"unexpected thread: {self.name}") + + monkeypatch.setattr(threading.Thread, "start", fail_start) + with pytest.raises(RuntimeError, match="thread start blocked"): + await adapter.start() + + subscription = stream.subscription + assert subscription is not None + first_retry = await adapter.stop() + assert not first_retry.resources_closed + assert any(item.resource == "turn-stream" for item in first_retry.failures) + assert adapter._stream_subscription is subscription + assert not subscription.closed + + second_retry = await adapter.stop() + assert second_retry.resources_closed + assert second_retry.failures == () + assert subscription.close_calls == 3 + assert subscription.closed + assert adapter._stream_subscription is None + + @pytest.mark.asyncio async def test_stop_failure_retains_provider_owner_for_exact_retry() -> None: class FlakyClient(FakeProviderClient): From fc2b4ba11511d957b0d4202f7e3aba40605927b0 Mon Sep 17 00:00:00 2001 From: huashen <2494946808@qq.com> Date: Mon, 17 Aug 2026 21:55:07 +0800 Subject: [PATCH 06/11] fix(channel): preserve Feishu inbound and cleanup --- channel.py | 55 +++++++++++++-- tests/test_plugin.py | 158 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 207 insertions(+), 6 deletions(-) diff --git a/channel.py b/channel.py index b47d153..c41e613 100644 --- a/channel.py +++ b/channel.py @@ -276,6 +276,7 @@ async def stop(self) -> StopReceipt: and self._client is None and self._provider_client is None ): + self._clear_transient_state() return StopReceipt(self._binding_token, resources_closed=True) self._stopping = True failures: list[ChannelCleanupFailure] = [] @@ -318,9 +319,12 @@ async def stop(self) -> StopReceipt: tasks = tuple(self._inbound_tasks) for task in tasks: task.cancel() - if tasks: - await asyncio.gather(*tasks, return_exceptions=True) - self._inbound_tasks.clear() + try: + if tasks: + await asyncio.gather(*tasks, return_exceptions=True) + finally: + self._inbound_tasks.clear() + self._clear_transient_state() # 4. Release adapter-owned formal resources; Core closes the factory separately. if self._client is not None: @@ -528,7 +532,14 @@ async def _ingest_message( sender = open_id or user_id or union_id if not sender: return DeliveryStatus.REJECTED - inbound_text, reply_meta = await self._merge_reply_context(message, content) + is_stop = content.strip() == "/stop" + if is_stop: + # Control messages must not fetch or merge reply context first. + inbound_text = content.strip() + reply_meta: dict[str, str] = {} + else: + inbound_text, reply_meta = await self._merge_reply_context(message, content) + inbound_text = _visible_text(inbound_text) raw_message = ChannelInboundMessage( channel=_CHANNEL, sender=sender, @@ -550,7 +561,7 @@ async def _ingest_message( provider_identity=sender, recipient=chat_id, ) - if content.strip() == "/stop": + if is_stop: return await self._interrupt(raw) if self._ingress is None: raise RuntimeError("Feishu ingress port 未绑定") @@ -678,7 +689,7 @@ async def _on_turn_stream(self, event: TurnStreamEvent) -> PresentationReceipt: finally: self._clear_presentation(event.presentation_id, payload.turn_id) except asyncio.CancelledError: - self._failed_presentations.add(event.presentation_id) + self._clear_presentation(event.presentation_id, _turn_id(event)) raise except Exception as error: self._failed_presentations.add(event.presentation_id) @@ -765,6 +776,19 @@ def _clear_presentation(self, presentation_id: str, turn_id: str) -> None: self._failed_presentations.discard(presentation_id) self._rejected_presentations.discard(presentation_id) + def _clear_transient_state(self) -> None: + """Release all in-memory inbound and preview state during channel stop.""" + + self._inbound_recipients.clear() + self._turn_recipients.clear() + self._presentation_client_messages.clear() + self._reply_buffers.clear() + self._thinking_buffers.clear() + self._tool_lines.clear() + self._preview_messages.clear() + self._failed_presentations.clear() + self._rejected_presentations.clear() + # ------------------------------------------------------------------ # REST and delivery classifier @@ -1054,6 +1078,25 @@ def _extract_text(content: str) -> str: return str(parsed.get("text") or "").strip() +def _visible_text(value: str) -> str: + """Escape control characters into visible markers accepted by Core text DTOs.""" + + pieces: list[str] = [] + for char in value: + codepoint = ord(char) + if codepoint >= 32: + pieces.append(char) + elif char == "\n": + pieces.append(r"\n") + elif char == "\r": + pieces.append(r"\r") + elif char == "\t": + pieces.append(r"\t") + else: + pieces.append(f"\\x{codepoint:02x}") + return "".join(pieces) + + def _split_markdown(text: str, limit: int) -> list[str]: if len(text) <= limit: return [text] diff --git a/tests/test_plugin.py b/tests/test_plugin.py index 7f6c87a..23f71b8 100644 --- a/tests/test_plugin.py +++ b/tests/test_plugin.py @@ -630,6 +630,164 @@ async def test_stop_uses_exact_core_control_port() -> None: assert control.raw.message.content == "/stop" +@pytest.mark.asyncio +async def test_reply_stop_bypasses_parent_fetch_and_uses_exact_control_port() -> None: + control = FakeControl() + adapter = module.build_feishu_channel(_context(control=control)) + stream = FakeTurnStream() + adapter.attach_presentation( + ChannelPresentationPorts(control=control, turn_stream=stream) + ) + message = _message(content='{"text":"/stop"}') + message.parent_id = "parent-1" + + async def fail_parent_fetch(message_id: str) -> str: + raise AssertionError(f"reply parent must not be fetched for {message_id}") + + adapter._fetch_message_text = fail_parent_fetch # type: ignore[method-assign] + status = await adapter._ingest_message( + message, + "stop-reply-1", + "oc_chat", + "ou_sender", + "", + "", + ) + + assert status is DeliveryStatus.DELIVERED + assert control.raw is not None + assert control.raw.message.content == "/stop" + assert "reply_to_message_id" not in control.raw.message.metadata + + +@pytest.mark.asyncio +async def test_multiline_inbound_is_admitted_with_visible_control_markers() -> None: + ingress = FakeIngress() + stream = FakeTurnStream() + adapter = module.build_feishu_channel(_context(ingress=ingress, stream=stream)) + adapter.attach_presentation( + ChannelPresentationPorts(control=FakeControl(), turn_stream=stream) + ) + + status = await adapter._ingest_message( + _message(content='{"text":"hello\\nworld\\t!"}'), + "msg-lines", + "oc_chat", + "ou_sender", + "", + "", + ) + + assert status is DeliveryStatus.DELIVERED + assert ingress.raw[0].message.content == r"hello\nworld\t!" + assert all(ord(char) >= 32 for char in ingress.raw[0].message.content) + + +@pytest.mark.asyncio +async def test_reply_context_is_admitted_with_visible_control_markers() -> None: + ingress = FakeIngress() + stream = FakeTurnStream() + adapter = module.build_feishu_channel(_context(ingress=ingress, stream=stream)) + adapter.attach_presentation( + ChannelPresentationPorts(control=FakeControl(), turn_stream=stream) + ) + message = _message(content='{"text":"reply"}') + message.parent_id = "parent-1" + + async def fetch_parent(message_id: str) -> str: + assert message_id == "parent-1" + return "parent\nline\t!" + + adapter._fetch_message_text = fetch_parent # type: ignore[method-assign] + status = await adapter._ingest_message( + message, + "msg-reply", + "oc_chat", + "ou_sender", + "", + "", + ) + + assert status is DeliveryStatus.DELIVERED + assert ingress.raw[0].message.content == ( + r"【你正在回复一条历史消息】\n" + r"被回复消息:\nparent\nline\t!\n\n" + r"【你当前新消息】\nreply" + ) + assert ingress.raw[0].message.metadata["reply_to_message_id"] == "parent-1" + + +@pytest.mark.asyncio +async def test_stop_clears_all_transient_state_even_when_resources_already_closed() -> None: + adapter = module.build_feishu_channel(_context()) + adapter._inbound_recipients["msg-1"] = "oc_chat" + adapter._turn_recipients["turn-1"] = "oc_chat" + adapter._presentation_client_messages["preview-1"] = "msg-1" + adapter._reply_buffers["preview-1"] = "reply" + adapter._thinking_buffers["preview-1"] = "thinking" + adapter._tool_lines["preview-1"] = [] + adapter._preview_messages["preview-1"] = "provider-1" + adapter._failed_presentations.add("preview-1") + adapter._rejected_presentations.add("preview-2") + + receipt = await adapter.stop() + + assert receipt.resources_closed + assert adapter._inbound_recipients == {} + assert adapter._turn_recipients == {} + assert adapter._presentation_client_messages == {} + assert adapter._reply_buffers == {} + assert adapter._thinking_buffers == {} + assert adapter._tool_lines == {} + assert adapter._preview_messages == {} + assert adapter._failed_presentations == set() + assert adapter._rejected_presentations == set() + + adapter._stopping = True + adapter._inbound_recipients["msg-2"] = "oc_chat" + second = await adapter.stop() + assert second.resources_closed + assert adapter._inbound_recipients == {} + + +@pytest.mark.asyncio +async def test_cancelled_turn_stream_clears_preview_and_recipient_state() -> None: + stream = FakeTurnStream() + adapter = module.build_feishu_channel(_context(stream=stream)) + adapter.attach_presentation( + ChannelPresentationPorts(control=FakeControl(), turn_stream=stream) + ) + adapter._inbound_recipients["msg-cancel"] = "oc_chat" + waiting = asyncio.Event() + + async def block_preview(event, recipient: str, *, live: bool): + await waiting.wait() + + adapter._sync_preview = block_preview # type: ignore[method-assign] + started = TurnStreamEvent( + "preview:cancel", + TurnStreamEventKind.TURN_STARTED, + TurnStartedPresentation("turn-cancel", "msg-cancel"), + ) + task = asyncio.create_task(adapter._on_turn_stream(started)) + await asyncio.sleep(0) + assert adapter._turn_recipients == {"turn-cancel": "oc_chat"} + task.cancel() + + with pytest.raises(asyncio.CancelledError): + await task + + assert adapter._inbound_recipients == {} + assert adapter._turn_recipients == {} + assert adapter._presentation_client_messages == {} + assert adapter._reply_buffers == {} + assert adapter._thinking_buffers == {} + assert adapter._tool_lines == {} + assert adapter._preview_messages == {} + assert adapter._failed_presentations == set() + assert adapter._rejected_presentations == set() + + @pytest.mark.asyncio async def test_turn_stream_keeps_one_preview_id_and_final_summary_patch() -> None: stream = FakeTurnStream() From 4046ba25dd2f1f1f109a2d34f2d73c93feae3d69 Mon Sep 17 00:00:00 2001 From: huashen <2494946808@qq.com> Date: Thu, 20 Aug 2026 14:56:14 +0800 Subject: [PATCH 07/11] feat(feishu): support v3 attachment delivery --- README.md | 9 +- channel.py | 414 ++++++++++++++++++++++++++++++++++++++++--- tests/test_plugin.py | 185 +++++++++++++++++-- 3 files changed, 563 insertions(+), 45 deletions(-) diff --git a/README.md b/README.md index c002f06..1f1303a 100644 --- a/README.md +++ b/README.md @@ -5,9 +5,12 @@ inbound admission, identity mapping, `/stop`, turn-stream lifecycle, delivery identity, and persistent state; this repository only translates Feishu's provider protocol. -The first v3 adapter is deliberately text-only. Incoming and outgoing image, -file, and rich-post attachments are returned as deterministic `REJECTED` -without reading a workspace path, importing bytes, or uploading provider data. +The v3 adapter accepts Core-owned image and file references for outbound delivery: +it verifies the exact reference and bounded bytes before Feishu upload, then +sends text and attachments in request order. Incoming image, file, and rich-post +media are downloaded through the provider API and imported into Core's artifact +store before one ingress admission. Provider effects use `DELIVERED`, +`REJECTED`, and `UNKNOWN` without exposing workspace paths. The previous v2 installation and its data remain available for an explicit, append-only migration; no v2 class or ABI is loaded by this artifact. Feishu owns no plugin database or attachment store, so this migration has no diff --git a/channel.py b/channel.py index c41e613..35d1335 100644 --- a/channel.py +++ b/channel.py @@ -7,12 +7,14 @@ from __future__ import annotations import asyncio +import hashlib import json import logging +import mimetypes import threading import time import warnings -from collections.abc import Callable, Coroutine, Mapping +from collections.abc import AsyncIterable, Callable, Coroutine, Mapping from dataclasses import dataclass from datetime import datetime, timezone from typing import Any, cast @@ -20,6 +22,8 @@ import httpx from agent.plugin_composition.channels import ( + AttachmentKind, + AttachmentRef, ChannelAdapter, ChannelCleanupFailure, ChannelFactoryContext, @@ -62,6 +66,7 @@ "app_id": ("appId", "app_id"), "app_secret": ("appSecret", "app_secret"), } +_MAX_ATTACHMENT_BYTES = 50 * 1024 * 1024 @dataclass(slots=True) @@ -214,45 +219,70 @@ async def start(self) -> ChannelReady: raise async def deliver(self, request: ProviderDeliveryRequest) -> ProviderDeliveryReceipt: - """Deliver text only and return a settled provider receipt without retry.""" + """Read exact Core attachments, send them in order, and settle one receipt.""" if not isinstance(request, ProviderDeliveryRequest): raise TypeError("Feishu deliver 只接受 ProviderDeliveryRequest") if request.binding_token != self._binding_token: raise RuntimeError("Feishu delivery binding token 不匹配") - if request.attachments: + if not request.body.strip() and not request.attachments: return ProviderDeliveryReceipt( request.delivery_id, DeliveryStatus.REJECTED, - error="Feishu v3 首批 adapter 只支持文本,附件未被读取或上传", + error="Feishu 空消息被拒绝", ) - if not request.body.strip(): + if self._client is None: + raise RuntimeError("Feishu adapter 尚未 start") + + # 1. Resolve and verify every Core-owned attachment before any provider effect. + try: + attachment_data = await self._read_attachments(request.attachments) + except asyncio.CancelledError: + raise + except (RuntimeError, TypeError, ValueError) as error: return ProviderDeliveryReceipt( request.delivery_id, DeliveryStatus.REJECTED, - error="Feishu 空消息被拒绝", + error=f"Feishu 附件读取失败: {error}", ) - if self._client is None: - raise RuntimeError("Feishu adapter 尚未 start") - # 1. Split before provider effect; every chunk retains the same delivery id. + # 2. Split before provider effect; every chunk retains the same delivery id. provider_ids: list[str] = [] - for chunk in _split_markdown(request.body, _CARD_TEXT_LIMIT): - status, provider_id, error = await self._send_one( - request.recipient, - "interactive", - build_markdown_card(chunk), - ) - if status is DeliveryStatus.REJECTED: - # 2. Only a proven pre-effect card rejection permits the old text fallback. + if request.body.strip(): + for chunk in _split_markdown(request.body, _CARD_TEXT_LIMIT): status, provider_id, error = await self._send_one( request.recipient, - "text", - json.dumps({"text": chunk}, ensure_ascii=False), + "interactive", + build_markdown_card(chunk), ) + if status is DeliveryStatus.REJECTED: + # 2. Only a proven pre-effect card rejection permits the old text fallback. + status, provider_id, error = await self._send_one( + request.recipient, + "text", + json.dumps({"text": chunk}, ensure_ascii=False), + ) + if provider_id: + provider_ids.append(provider_id) + if status is not DeliveryStatus.DELIVERED: + return ProviderDeliveryReceipt( + request.delivery_id, + status, + tuple(provider_ids), + error=error, + ) + # 3. Upload and send attachments in the exact request order. + for ref, data in attachment_data: + status, provider_id, error = await self._send_attachment( + request.recipient, + ref, + data, + ) if provider_id: provider_ids.append(provider_id) if status is not DeliveryStatus.DELIVERED: + if provider_ids and status is DeliveryStatus.REJECTED: + status = DeliveryStatus.UNKNOWN return ProviderDeliveryReceipt( request.delivery_id, status, @@ -265,6 +295,84 @@ async def deliver(self, request: ProviderDeliveryRequest) -> ProviderDeliveryRec tuple(provider_ids), ) + async def _read_attachments( + self, + refs: tuple[AttachmentRef, ...], + ) -> list[tuple[AttachmentRef, bytes]]: + """Read and hash-check Core attachments while retaining no path access.""" + + if not refs: + return [] + attachment_read = self._context.attachment_read + if attachment_read is None: + raise RuntimeError("Feishu outbound 附件缺少 Core attachment_read") + result: list[tuple[AttachmentRef, bytes]] = [] + for ref in refs: + lease = await attachment_read.acquire(ref) + try: + if lease.ref != ref: + raise RuntimeError("Feishu attachment read lease ref 不匹配") + data = await lease.read_bytes( + max_bytes=min(max(ref.size_bytes, 1), _MAX_ATTACHMENT_BYTES) + ) + if len(data) != ref.size_bytes: + raise ValueError( + f"附件大小不匹配: expected={ref.size_bytes} actual={len(data)}" + ) + if hashlib.sha256(data).hexdigest() != ref.sha256: + raise ValueError("附件 sha256 不匹配") + result.append((ref, data)) + finally: + await _close_attachment_lease(lease) + return result + + async def _send_attachment( + self, + recipient: str, + ref: AttachmentRef, + data: bytes, + ) -> tuple[DeliveryStatus, str | None, str | None]: + """Upload one verified attachment then send its provider message.""" + + try: + provider_key = await self._upload_attachment(ref, data) + except asyncio.CancelledError: + raise + except Exception as error: + return _feishu_error_status(error), None, str(error) or type(error).__name__ + try: + payload = await self._post_message_once( + recipient, + "image" if ref.kind is AttachmentKind.IMAGE else "file", + json.dumps( + { + "image_key" if ref.kind is AttachmentKind.IMAGE else "file_key": provider_key + }, + ensure_ascii=False, + ), + ) + except asyncio.CancelledError: + raise + except Exception as error: + # The upload already changed provider state; a send failure is not a + # deterministic no-effect rejection even when the HTTP status is 4xx. + return DeliveryStatus.UNKNOWN, None, str(error) or type(error).__name__ + provider_id = str(payload.get("message_id") or "").strip() + if not provider_id: + return DeliveryStatus.UNKNOWN, None, "Feishu response 缺少 message_id" + return DeliveryStatus.DELIVERED, provider_id, None + + async def _upload_attachment(self, ref: AttachmentRef, data: bytes) -> str: + """Upload verified bytes using the provider media endpoint.""" + + if ref.kind is AttachmentKind.IMAGE: + provider_key = await self._upload_image(data) + else: + provider_key = await self._upload_file(data, ref.filename or "attachment") + if not provider_key: + raise RuntimeError("Feishu media upload response 缺少 provider key") + return provider_key + async def stop(self) -> StopReceipt: """Close stream, websocket, tasks, HTTP client, and provider client exactly once.""" @@ -516,23 +624,26 @@ async def _ingest_message( user_id: str, union_id: str, ) -> DeliveryStatus: - """Admit text or return deterministic REJECTED for unsupported attachments.""" + """Download provider media into Core artifacts before one ingress admission.""" - message_type = str(getattr(message, "message_type", "") or "") - if message_type != "text": - logger.info( - "[feishu] v3 attachment input rejected message_id=%s type=%s", + try: + content, attachments = await self._extract_inbound_payload(message, message_id) + except asyncio.CancelledError: + raise + except (httpx.HTTPStatusError, FeishuApiError, RuntimeError, TypeError, ValueError) as error: + logger.warning( + "[feishu] 入站附件未能导入 message_id=%s err=%s", message_id, - message_type, + error, ) return DeliveryStatus.REJECTED - content = _extract_text(str(getattr(message, "content", "") or "")) - if not content: + if not content and not attachments: return DeliveryStatus.REJECTED sender = open_id or user_id or union_id if not sender: return DeliveryStatus.REJECTED - is_stop = content.strip() == "/stop" + content = _visible_text(content) + is_stop = content.strip() == "/stop" and not attachments if is_stop: # Control messages must not fetch or merge reply context first. inbound_text = content.strip() @@ -554,6 +665,7 @@ async def _ingest_message( "union_id": union_id, **reply_meta, }, + attachments=tuple(attachments), ) raw = RawInbound( message_id=message_id, @@ -571,6 +683,102 @@ async def _ingest_message( return DeliveryStatus.DELIVERED return DeliveryStatus.REJECTED + async def _extract_inbound_payload( + self, + message: Any, + message_id: str, + ) -> tuple[str, list[AttachmentRef]]: + """Translate Feishu text, image, file, or post content into Core values.""" + + message_type = str(getattr(message, "message_type", "") or "") + content_raw = str(getattr(message, "content", "") or "") + if message_type == "text": + return _extract_text(content_raw), [] + if message_type == "image": + image_key = _extract_key(content_raw, "image_key") + data = await self._download_resource_bytes(message_id, image_key, "image") + return "[图片]", [ + await self._import_inbound_attachment( + data, + kind=AttachmentKind.IMAGE, + filename="image.jpg", + media_type="image/jpeg", + ) + ] + if message_type == "file": + file_name = _extract_key(content_raw, "file_name") or "file" + file_key = _extract_key(content_raw, "file_key") + data = await self._download_resource_bytes(message_id, file_key, "file") + media_type = mimetypes.guess_type(file_name)[0] or "application/octet-stream" + return f"[文件: {file_name}]", [ + await self._import_inbound_attachment( + data, + kind=AttachmentKind.FILE, + filename=file_name, + media_type=media_type, + ) + ] + if message_type == "post": + text, image_keys = _extract_post(content_raw) + attachments: list[AttachmentRef] = [] + for index, image_key in enumerate(image_keys, start=1): + data = await self._download_resource_bytes(message_id, image_key, "image") + attachments.append( + await self._import_inbound_attachment( + data, + kind=AttachmentKind.IMAGE, + filename=f"image-{index}.jpg", + media_type="image/jpeg", + ) + ) + return text or "[富文本]", attachments + logger.debug("[feishu] 暂不支持的消息类型 msg_type=%s", message_type) + return "", [] + + async def _download_resource_bytes( + self, + message_id: str, + file_key: str, + resource_type: str, + ) -> bytes: + """Download one provider resource with a fixed memory bound.""" + + if not file_key: + raise ValueError("Feishu 资源缺少 provider key") + if self._client is None: + raise RuntimeError("Feishu HTTP client 尚未 start") + token = await self._get_access_token() + response = await self._client.get( + f"{self._domain}/open-apis/im/v1/messages/{message_id}/resources/{file_key}", + params={"type": resource_type}, + headers={"Authorization": f"Bearer {token}"}, + ) + response.raise_for_status() + return await _bounded_response_bytes(response) + + async def _import_inbound_attachment( + self, + data: bytes, + *, + kind: AttachmentKind, + filename: str, + media_type: str, + ) -> AttachmentRef: + """Import provider bytes through the exact Core attachment port.""" + + attachment_import = self._context.attachment_import + if attachment_import is None: + raise RuntimeError("Feishu 入站附件缺少 Core attachment_import") + ref = await attachment_import.import_bytes( + data, + kind=kind, + filename=filename, + media_type=media_type, + ) + if not isinstance(ref, AttachmentRef): + raise TypeError("Feishu attachment_import 必须返回 AttachmentRef") + return ref + async def _interrupt(self, raw: RawInbound) -> DeliveryStatus: """Delegate /stop to Core's exact control facade; never call an old controller.""" @@ -862,6 +1070,36 @@ async def _post_message_once( ) return self._check_response(response) + async def _upload_image(self, data: bytes) -> str: + """Upload one Core-owned image and return Feishu's opaque image key.""" + + if self._client is None: + raise RuntimeError("Feishu HTTP client 尚未 start") + token = await self._get_access_token() + response = await self._client.post( + f"{self._domain}/open-apis/im/v1/images", + headers={"Authorization": f"Bearer {token}"}, + data={"image_type": "message"}, + files={"image": ("image", data)}, + ) + payload = self._check_response(response) + return str(payload.get("image_key") or "").strip() + + async def _upload_file(self, data: bytes, filename: str) -> str: + """Upload one Core-owned file and return Feishu's opaque file key.""" + + if self._client is None: + raise RuntimeError("Feishu HTTP client 尚未 start") + token = await self._get_access_token() + response = await self._client.post( + f"{self._domain}/open-apis/im/v1/files", + headers={"Authorization": f"Bearer {token}"}, + data={"file_type": "stream", "file_name": filename}, + files={"file": (filename, data)}, + ) + payload = self._check_response(response) + return str(payload.get("file_key") or "").strip() + async def _patch_message_once(self, message_id: str, content: str) -> dict[str, Any]: if self._client is None: raise RuntimeError("Feishu HTTP client 尚未 start") @@ -1078,6 +1316,65 @@ def _extract_text(content: str) -> str: return str(parsed.get("text") or "").strip() +def _extract_key(content: str, key: str) -> str: + """Read one string key from a Feishu message content object.""" + + try: + parsed = json.loads(content) + except json.JSONDecodeError: + return "" + if not isinstance(parsed, dict): + return "" + value = parsed.get(key) + return value.strip() if isinstance(value, str) else "" + + +def _extract_post(content: str) -> tuple[str, list[str]]: + """Flatten Feishu post paragraphs while collecting embedded image keys.""" + + try: + parsed = json.loads(content) + except json.JSONDecodeError: + return content.strip(), [] + if not isinstance(parsed, dict): + return "", [] + body = parsed + if "content" not in body: + for value in body.values(): + if isinstance(value, dict) and "content" in value: + body = value + break + texts: list[str] = [] + images: list[str] = [] + title = body.get("title") + if isinstance(title, str) and title.strip(): + texts.append(title.strip()) + paragraphs = body.get("content") + if isinstance(paragraphs, list): + for paragraph in paragraphs: + if not isinstance(paragraph, list): + continue + parts: list[str] = [] + for segment in paragraph: + if not isinstance(segment, dict): + continue + tag = str(segment.get("tag") or "") + if tag == "text": + parts.append(str(segment.get("text") or "")) + elif tag in {"a", "link"}: + parts.append(str(segment.get("text") or segment.get("href") or "")) + elif tag == "at": + parts.append("@" + str(segment.get("user_name") or segment.get("user_id") or "")) + elif tag == "img": + image_key = segment.get("image_key") + if isinstance(image_key, str) and image_key.strip(): + images.append(image_key.strip()) + line = "".join(parts).strip() + if line: + texts.append(line) + return "\n".join(texts).strip(), images + + def _visible_text(value: str) -> str: """Escape control characters into visible markers accepted by Core text DTOs.""" @@ -1116,3 +1413,64 @@ def _split_markdown(text: str, limit: int) -> list[str]: if current: chunks.append("".join(current)) return chunks + + +def _feishu_error_status(error: BaseException) -> DeliveryStatus: + """Classify a provider error without treating transport failure as rejection.""" + + if isinstance(error, httpx.HTTPStatusError): + return ( + DeliveryStatus.REJECTED + if error.response.status_code in _REJECTED_HTTP_STATUSES + else DeliveryStatus.UNKNOWN + ) + if isinstance(error, FeishuApiError): + return ( + DeliveryStatus.UNKNOWN + if error.code in _RATE_LIMIT_CODES + else DeliveryStatus.REJECTED + ) + return DeliveryStatus.UNKNOWN + + +async def _close_attachment_lease(lease: Any) -> None: + """Finish attachment lease cleanup even when the caller is cancelled.""" + + task = asyncio.create_task(lease.aclose(), name="feishu-attachment-lease-close") + cancelled = False + while not task.done(): + try: + await asyncio.shield(task) + except asyncio.CancelledError: + cancelled = True + continue + if task.cancelled(): + raise asyncio.CancelledError + result = task.result() + if cancelled: + raise asyncio.CancelledError + return result + + +async def _bounded_response_bytes(response: Any) -> bytes: + """Collect a provider response without exceeding the attachment memory bound.""" + + chunks: list[bytes] = [] + total = 0 + aiter_bytes = getattr(response, "aiter_bytes", None) + if callable(aiter_bytes): + aiter_bytes = cast(Callable[[], AsyncIterable[bytes]], aiter_bytes) + async for chunk in aiter_bytes(): + if not isinstance(chunk, bytes): + raise TypeError("Feishu provider response chunk 必须是 bytes") + total += len(chunk) + if total > _MAX_ATTACHMENT_BYTES: + raise ValueError("Feishu 入站附件超过大小上限") + chunks.append(chunk) + return b"".join(chunks) + data = response.content + if not isinstance(data, bytes): + raise TypeError("Feishu provider response content 必须是 bytes") + if len(data) > _MAX_ATTACHMENT_BYTES: + raise ValueError("Feishu 入站附件超过大小上限") + return data diff --git a/tests/test_plugin.py b/tests/test_plugin.py index 23f71b8..6df9998 100644 --- a/tests/test_plugin.py +++ b/tests/test_plugin.py @@ -1,6 +1,7 @@ from __future__ import annotations import asyncio +import hashlib import importlib.util import sys import threading @@ -10,6 +11,8 @@ import pytest from agent.plugin_composition.channels import ( + AttachmentKind, + AttachmentRef, ChannelDeliveryReceipt, ChannelFactoryContext, ChannelInboundMessage, @@ -152,6 +155,58 @@ def subscribe(self, callback) -> FakeSubscription: return self.subscription +class FakeAttachmentReadLease: + def __init__(self, ref: AttachmentRef, data: bytes) -> None: + self.ref = ref + self.data = data + self.closed = False + self.max_bytes: int | None = None + + async def read_bytes(self, *, max_bytes: int) -> bytes: + self.max_bytes = max_bytes + if len(self.data) > max_bytes: + raise ValueError("read exceeded bound") + return self.data + + async def aclose(self) -> None: + self.closed = True + + +class FakeAttachmentRead: + def __init__(self, values: dict[str, tuple[AttachmentRef, bytes]] | None = None) -> None: + self.values = values or {} + self.leases: list[FakeAttachmentReadLease] = [] + + async def acquire(self, ref: AttachmentRef) -> FakeAttachmentReadLease: + actual_ref, data = self.values.get(ref.artifact_id, (ref, b"")) + lease = FakeAttachmentReadLease(actual_ref, data) + self.leases.append(lease) + return lease + + +class FakeAttachmentImport: + def __init__(self) -> None: + self.calls: list[tuple[bytes, AttachmentKind, str | None, str | None]] = [] + + async def import_bytes( + self, + data: bytes, + *, + kind: AttachmentKind, + filename: str | None, + media_type: str | None, + ) -> AttachmentRef: + self.calls.append((data, kind, filename, media_type)) + return AttachmentRef( + artifact_id=f"imported-{len(self.calls)}", + kind=kind, + filename=filename, + media_type=media_type, + size_bytes=len(data), + sha256=hashlib.sha256(data).hexdigest(), + ) + + def _context( *, factory: FakeProviderFactory | None = None, @@ -159,6 +214,8 @@ def _context( identity: FakeIdentity | None = None, control: FakeControl | None = None, stream: FakeTurnStream | None = None, + attachment_read: FakeAttachmentRead | None = None, + attachment_import: FakeAttachmentImport | None = None, config: dict[str, object] | None = None, ) -> ChannelFactoryContext: return ChannelFactoryContext( @@ -177,6 +234,8 @@ def _context( provider_client_factory=factory or FakeProviderFactory(), ingress=ingress or FakeIngress(), identity=identity or FakeIdentity(), + attachment_import=attachment_import or FakeAttachmentImport(), + attachment_read=attachment_read or FakeAttachmentRead(), control=control or FakeControl(), turn_stream=stream or FakeTurnStream(), ) @@ -463,23 +522,41 @@ def __init__(self) -> None: @pytest.mark.asyncio -async def test_attachment_delivery_is_deterministic_rejected_without_provider_effect() -> None: +async def test_attachment_delivery_reads_exact_bytes_and_preserves_text_file_order() -> None: factory = FakeProviderFactory() stream = FakeTurnStream() - adapter = module.build_feishu_channel(_context(factory=factory, stream=stream)) - adapter.attach_presentation( - ChannelPresentationPorts(control=FakeControl(), turn_stream=stream) - ) - from agent.plugin_composition.channels import AttachmentKind, AttachmentRef - + data = b"x" attachment = AttachmentRef( artifact_id="artifact-1", kind=AttachmentKind.FILE, filename="a.txt", media_type="text/plain", - size_bytes=1, - sha256="0" * 64, + size_bytes=len(data), + sha256=hashlib.sha256(data).hexdigest(), ) + read = FakeAttachmentRead({attachment.artifact_id: (attachment, data)}) + adapter = module.build_feishu_channel( + _context(factory=factory, stream=stream, attachment_read=read) + ) + adapter._run_ws_client = lambda: adapter._ws_stopped.wait() + adapter.attach_presentation( + ChannelPresentationPorts(control=FakeControl(), turn_stream=stream) + ) + await adapter.start() + calls: list[tuple[str, str]] = [] + + async def post(recipient: str, message_type: str, content: str): + calls.append(("post", message_type)) + return {"message_id": f"provider-{len(calls)}"} + + async def upload(data: bytes, filename: str): + assert data == b"x" + assert filename == "a.txt" + calls.append(("upload", filename)) + return "file-key" + + adapter._post_message_once = post + adapter._upload_file = upload receipt = await adapter.deliver( ProviderDeliveryRequest( binding_token="binding-1", @@ -489,8 +566,81 @@ async def test_attachment_delivery_is_deterministic_rejected_without_provider_ef attachments=(attachment,), ) ) + assert receipt.status is DeliveryStatus.DELIVERED + assert calls == [("post", "interactive"), ("upload", "a.txt"), ("post", "file")] + assert read.leases[0].max_bytes == 1 + assert read.leases[0].closed + await adapter.stop() + + +@pytest.mark.asyncio +async def test_attachment_upload_failure_is_rejected_without_attachment_message() -> None: + stream = FakeTurnStream() + data = b"x" + attachment = AttachmentRef( + artifact_id="artifact-failure", + kind=AttachmentKind.FILE, + filename="a.txt", + media_type="text/plain", + size_bytes=1, + sha256=hashlib.sha256(data).hexdigest(), + ) + adapter = module.build_feishu_channel( + _context( + stream=stream, + attachment_read=FakeAttachmentRead({"artifact-failure": (attachment, data)}), + ) + ) + adapter._run_ws_client = lambda: adapter._ws_stopped.wait() + adapter.attach_presentation(ChannelPresentationPorts(FakeControl(), stream)) + await adapter.start() + calls: list[str] = [] + + async def post(recipient: str, message_type: str, content: str): + calls.append(message_type) + return {"message_id": "text-id"} + + async def upload(data: bytes, filename: str): + raise module.channel.FeishuApiError(123, "invalid media") + + adapter._post_message_once = post + adapter._upload_file = upload + receipt = await adapter.deliver( + ProviderDeliveryRequest("binding-1", "delivery-failure", "oc_chat", "", (attachment,)) + ) assert receipt.status is DeliveryStatus.REJECTED - assert factory.create_calls == 0 + assert calls == [] + await adapter.stop() + + +@pytest.mark.asyncio +async def test_attachment_delivery_propagates_cancel_and_closes_read_lease() -> None: + stream = FakeTurnStream() + data = b"x" + attachment = AttachmentRef( + artifact_id="artifact-cancel", + kind=AttachmentKind.FILE, + filename="a.txt", + media_type="text/plain", + size_bytes=1, + sha256=hashlib.sha256(data).hexdigest(), + ) + read = FakeAttachmentRead({"artifact-cancel": (attachment, data)}) + adapter = module.build_feishu_channel(_context(stream=stream, attachment_read=read)) + adapter._run_ws_client = lambda: adapter._ws_stopped.wait() + adapter.attach_presentation(ChannelPresentationPorts(FakeControl(), stream)) + await adapter.start() + + async def upload(data: bytes, filename: str): + raise asyncio.CancelledError + + adapter._upload_file = upload + with pytest.raises(asyncio.CancelledError): + await adapter.deliver( + ProviderDeliveryRequest("binding-1", "delivery-cancel", "oc_chat", "", (attachment,)) + ) + assert read.leases[0].closed + await adapter.stop() @pytest.mark.asyncio @@ -534,7 +684,7 @@ async def uncertain(recipient: str, message_type: str, content: str): @pytest.mark.asyncio -async def test_text_inbound_admits_raw_message_and_attachment_is_rejected() -> None: +async def test_text_and_image_inbound_import_core_attachment_before_admission() -> None: ingress = FakeIngress() stream = FakeTurnStream() adapter = module.build_feishu_channel(_context(ingress=ingress, stream=stream)) @@ -548,7 +698,12 @@ async def test_text_inbound_admits_raw_message_and_attachment_is_rejected() -> N assert ingress.raw[0].provider_identity == "ou_sender" assert ingress.raw[0].recipient == "oc_chat" assert ingress.raw[0].message.content == "hello" - rejected = await adapter._ingest_message( + async def download(message_id: str, file_key: str, resource_type: str) -> bytes: + assert (message_id, file_key, resource_type) == ("msg-2", "img", "image") + return b"image-bytes" + + adapter._download_resource_bytes = download + image = await adapter._ingest_message( _message(message_type="image", content='{"image_key":"img"}'), "msg-2", "oc_chat", @@ -556,8 +711,10 @@ async def test_text_inbound_admits_raw_message_and_attachment_is_rejected() -> N "", "", ) - assert rejected is DeliveryStatus.REJECTED - assert len(ingress.raw) == 1 + assert image is DeliveryStatus.DELIVERED + assert len(ingress.raw) == 2 + assert ingress.raw[1].message.content == "[图片]" + assert ingress.raw[1].message.attachments[0].size_bytes == len(b"image-bytes") @pytest.mark.asyncio From aaf7abe4eb5235f98bd1c4f53aec261ba7866080 Mon Sep 17 00:00:00 2001 From: huashen <2494946808@qq.com> Date: Thu, 20 Aug 2026 15:32:06 +0800 Subject: [PATCH 08/11] fix(feishu): close v3 attachment and ingress boundaries --- channel.py | 243 +++++++++++++++++++++++++++++++++++++------ tests/test_plugin.py | 172 ++++++++++++++++++++++++++++++ 2 files changed, 386 insertions(+), 29 deletions(-) diff --git a/channel.py b/channel.py index 35d1335..4edc16d 100644 --- a/channel.py +++ b/channel.py @@ -67,6 +67,9 @@ "app_secret": ("appSecret", "app_secret"), } _MAX_ATTACHMENT_BYTES = 50 * 1024 * 1024 +_MAX_ATTACHMENT_COUNT = 16 +_MAX_ATTACHMENT_BATCH_BYTES = 100 * 1024 * 1024 +_MAX_PROVIDER_SEGMENT_LENGTH = 256 @dataclass(slots=True) @@ -141,6 +144,8 @@ def __init__(self, context: ChannelFactoryContext) -> None: self._loop: asyncio.AbstractEventLoop | None = None self._started = False self._stopping = False + self._admission_open = False + self._runtime: Any | None = None self._ws_client: Any | None = None self._ws_loop: asyncio.AbstractEventLoop | None = None @@ -170,6 +175,29 @@ def attach_presentation(self, ports: ChannelPresentationPorts) -> None: raise RuntimeError("Feishu v3 必须同时绑定 control 与 turn_stream") self._presentation = ports + def attach_runtime(self, runtime: Any) -> None: + """Bind the exact Host runtime lifecycle owner without replacing context ports.""" + + if self._runtime is not None: + raise RuntimeError("Feishu runtime 不能重复绑定") + if runtime is None: + raise TypeError("Feishu runtime 不能为空") + if getattr(runtime, "binding_token", None) != self._binding_token: + raise RuntimeError("Feishu runtime binding token 不匹配") + self._runtime = runtime + + def open_admission(self) -> None: + """Allow provider ingress only after Core has published this binding.""" + + if self._stopping: + raise RuntimeError("Feishu adapter 正在停止") + self._admission_open = True + + def close_admission(self) -> None: + """Reject new provider ingress while accepted callback tasks drain.""" + + self._admission_open = False + async def start(self) -> ChannelReady: """Resolve formal credentials, create the provider client, and start closed.""" @@ -248,6 +276,7 @@ async def deliver(self, request: ProviderDeliveryRequest) -> ProviderDeliveryRec # 2. Split before provider effect; every chunk retains the same delivery id. provider_ids: list[str] = [] + delivered_any = False if request.body.strip(): for chunk in _split_markdown(request.body, _CARD_TEXT_LIMIT): status, provider_id, error = await self._send_one( @@ -264,7 +293,11 @@ async def deliver(self, request: ProviderDeliveryRequest) -> ProviderDeliveryRec ) if provider_id: provider_ids.append(provider_id) + if status is DeliveryStatus.DELIVERED: + delivered_any = True if status is not DeliveryStatus.DELIVERED: + if delivered_any and status is DeliveryStatus.REJECTED: + status = DeliveryStatus.UNKNOWN return ProviderDeliveryReceipt( request.delivery_id, status, @@ -280,8 +313,10 @@ async def deliver(self, request: ProviderDeliveryRequest) -> ProviderDeliveryRec ) if provider_id: provider_ids.append(provider_id) + if status is DeliveryStatus.DELIVERED: + delivered_any = True if status is not DeliveryStatus.DELIVERED: - if provider_ids and status is DeliveryStatus.REJECTED: + if delivered_any and status is DeliveryStatus.REJECTED: status = DeliveryStatus.UNKNOWN return ProviderDeliveryReceipt( request.delivery_id, @@ -303,10 +338,16 @@ async def _read_attachments( if not refs: return [] + if len(refs) > _MAX_ATTACHMENT_COUNT: + raise ValueError("Feishu 附件数量超过上限") + declared_total = sum(ref.size_bytes for ref in refs) + if declared_total > _MAX_ATTACHMENT_BATCH_BYTES: + raise ValueError("Feishu 附件批次超过总大小上限") attachment_read = self._context.attachment_read if attachment_read is None: raise RuntimeError("Feishu outbound 附件缺少 Core attachment_read") result: list[tuple[AttachmentRef, bytes]] = [] + actual_total = 0 for ref in refs: lease = await attachment_read.acquire(ref) try: @@ -321,6 +362,9 @@ async def _read_attachments( ) if hashlib.sha256(data).hexdigest() != ref.sha256: raise ValueError("附件 sha256 不匹配") + actual_total += len(data) + if actual_total > _MAX_ATTACHMENT_BATCH_BYTES: + raise ValueError("Feishu 附件批次超过总大小上限") result.append((ref, data)) finally: await _close_attachment_lease(lease) @@ -338,6 +382,8 @@ async def _send_attachment( provider_key = await self._upload_attachment(ref, data) except asyncio.CancelledError: raise + except ValueError as error: + return DeliveryStatus.REJECTED, None, str(error) except Exception as error: return _feishu_error_status(error), None, str(error) or type(error).__name__ try: @@ -360,6 +406,10 @@ async def _send_attachment( provider_id = str(payload.get("message_id") or "").strip() if not provider_id: return DeliveryStatus.UNKNOWN, None, "Feishu response 缺少 message_id" + try: + _provider_segment(provider_id, "Feishu message_id") + except ValueError as error: + return DeliveryStatus.UNKNOWN, None, str(error) return DeliveryStatus.DELIVERED, provider_id, None async def _upload_attachment(self, ref: AttachmentRef, data: bytes) -> str: @@ -371,6 +421,7 @@ async def _upload_attachment(self, ref: AttachmentRef, data: bytes) -> str: provider_key = await self._upload_file(data, ref.filename or "attachment") if not provider_key: raise RuntimeError("Feishu media upload response 缺少 provider key") + _provider_segment(provider_key, "Feishu file_key") return provider_key async def stop(self) -> StopReceipt: @@ -402,6 +453,7 @@ async def stop(self) -> StopReceipt: # 2. Stop the provider receive loop before closing its HTTP resources. self._ws_stopped.set() + self._admission_open = False try: await self._disconnect_ws() self._ws_client = None @@ -425,11 +477,14 @@ async def stop(self) -> StopReceipt: # 3. Complete in-process callback cleanup before returning the receipt. tasks = tuple(self._inbound_tasks) - for task in tasks: - task.cancel() try: if tasks: - await asyncio.gather(*tasks, return_exceptions=True) + results = await asyncio.gather(*tasks, return_exceptions=True) + for result in results: + if isinstance(result, BaseException) and not isinstance( + result, asyncio.CancelledError + ): + failures.append(self._cleanup_failure("inbound-task", result)) finally: self._inbound_tasks.clear() self._clear_transient_state() @@ -567,12 +622,12 @@ async def _disconnect_ws(self) -> None: def _on_sdk_message(self, event: Any) -> None: loop = self._loop - if loop is None or self._ws_stopped.is_set(): + if loop is None or self._ws_stopped.is_set() or not self._admission_open: return loop.call_soon_threadsafe(self._start_inbound_task, event) def _start_inbound_task(self, event: Any) -> None: - if self._ws_stopped.is_set(): + if self._ws_stopped.is_set() or not self._admission_open: return task = asyncio.create_task( self._handle_message_event(event), @@ -584,6 +639,10 @@ def _start_inbound_task(self, event: Any) -> None: async def _handle_message_event(self, event: Any) -> DeliveryStatus | None: """Project one SDK event into text ingress or an exact Core control port.""" + if not self._admission_open: + logger.warning("[feishu] Core admission 尚未打开,拒绝 provider 入站") + return DeliveryStatus.REJECTED + data = getattr(event, "event", None) message = getattr(data, "message", None) sender = getattr(data, "sender", None) @@ -595,6 +654,11 @@ async def _handle_message_event(self, event: Any) -> DeliveryStatus | None: if not message_id: logger.warning("[feishu] 丢弃缺少 provider message id 的事件") return DeliveryStatus.REJECTED + try: + _provider_segment(message_id, "Feishu message_id") + except ValueError as error: + logger.warning("[feishu] 丢弃非法 provider message id: %s", error) + return DeliveryStatus.REJECTED sender_id = getattr(sender, "sender_id", None) open_id = str(getattr(sender_id, "open_id", "") or "").strip() user_id = str(getattr(sender_id, "user_id", "") or "").strip() @@ -626,11 +690,53 @@ async def _ingest_message( ) -> DeliveryStatus: """Download provider media into Core artifacts before one ingress admission.""" + # 1. Decide /stop before touching provider media. An attachment-bearing + # control message is rejected without creating a Core artifact. + stop_candidate, has_media = _stop_candidate(message) + if stop_candidate.strip() == "/stop": + if has_media: + logger.warning( + "[feishu] 拒绝带附件的 /stop message_id=%s", + message_id, + ) + return DeliveryStatus.REJECTED + sender = open_id or user_id or union_id + if not sender: + return DeliveryStatus.REJECTED + raw = RawInbound( + message_id=message_id, + message=ChannelInboundMessage( + channel=_CHANNEL, + sender=sender, + chat_id=chat_id, + content="/stop", + timestamp=_message_timestamp(message), + metadata={ + "chat_type": "private", + "provider_message_id": message_id, + "open_id": open_id, + "user_id": user_id, + "union_id": union_id, + }, + ), + provider_identity=sender, + recipient=chat_id, + ) + return await self._interrupt(raw) + + # 2. Download and import only ordinary inbound messages. try: content, attachments = await self._extract_inbound_payload(message, message_id) except asyncio.CancelledError: raise - except (httpx.HTTPStatusError, FeishuApiError, RuntimeError, TypeError, ValueError) as error: + except ( + httpx.HTTPStatusError, + httpx.RequestError, + FeishuApiError, + RuntimeError, + TypeError, + ValueError, + ) as error: logger.warning( "[feishu] 入站附件未能导入 message_id=%s err=%s", message_id, @@ -697,6 +803,7 @@ async def _extract_inbound_payload( if message_type == "image": image_key = _extract_key(content_raw, "image_key") data = await self._download_resource_bytes(message_id, image_key, "image") + _check_inbound_batch((data,)) return "[图片]", [ await self._import_inbound_attachment( data, @@ -709,6 +816,7 @@ async def _extract_inbound_payload( file_name = _extract_key(content_raw, "file_name") or "file" file_key = _extract_key(content_raw, "file_key") data = await self._download_resource_bytes(message_id, file_key, "file") + _check_inbound_batch((data,)) media_type = mimetypes.guess_type(file_name)[0] or "application/octet-stream" return f"[文件: {file_name}]", [ await self._import_inbound_attachment( @@ -720,9 +828,15 @@ async def _extract_inbound_payload( ] if message_type == "post": text, image_keys = _extract_post(content_raw) + if len(image_keys) > _MAX_ATTACHMENT_COUNT: + raise ValueError("Feishu 入站附件数量超过上限") attachments: list[AttachmentRef] = [] + downloaded: list[bytes] = [] for index, image_key in enumerate(image_keys, start=1): data = await self._download_resource_bytes(message_id, image_key, "image") + downloaded.append(data) + _check_inbound_batch(downloaded) + for index, data in enumerate(downloaded, start=1): attachments.append( await self._import_inbound_attachment( data, @@ -743,18 +857,20 @@ async def _download_resource_bytes( ) -> bytes: """Download one provider resource with a fixed memory bound.""" - if not file_key: - raise ValueError("Feishu 资源缺少 provider key") + _provider_segment(message_id, "Feishu message_id") + _provider_segment(file_key, "Feishu file_key") if self._client is None: raise RuntimeError("Feishu HTTP client 尚未 start") token = await self._get_access_token() - response = await self._client.get( + async with self._client.stream( + "GET", f"{self._domain}/open-apis/im/v1/messages/{message_id}/resources/{file_key}", params={"type": resource_type}, headers={"Authorization": f"Bearer {token}"}, - ) - response.raise_for_status() - return await _bounded_response_bytes(response) + follow_redirects=False, + ) as response: + response.raise_for_status() + return await _bounded_response_bytes(response) async def _import_inbound_attachment( self, @@ -1010,6 +1126,8 @@ async def _send_one( payload = await self._post_message_once(recipient, message_type, content) except asyncio.CancelledError: raise + except ValueError as error: + return DeliveryStatus.REJECTED, None, str(error) except httpx.HTTPStatusError as error: status = error.response.status_code if status in _REJECTED_HTTP_STATUSES: @@ -1019,11 +1137,17 @@ async def _send_one( if error.code in _RATE_LIMIT_CODES: return DeliveryStatus.UNKNOWN, None, str(error) return DeliveryStatus.REJECTED, None, str(error) + except httpx.RequestError as error: + return _feishu_error_status(error), None, str(error) or type(error).__name__ except Exception as error: return DeliveryStatus.UNKNOWN, None, str(error) or type(error).__name__ provider_id = str(payload.get("message_id") or "").strip() if not provider_id: return DeliveryStatus.UNKNOWN, None, "Feishu response 缺少 message_id" + try: + _provider_segment(provider_id, "Feishu message_id") + except ValueError as error: + return DeliveryStatus.UNKNOWN, None, str(error) return DeliveryStatus.DELIVERED, provider_id, None async def _patch_one( @@ -1032,6 +1156,7 @@ async def _patch_one( content: str, ) -> tuple[DeliveryStatus, str | None]: try: + _provider_segment(message_id, "Feishu message_id") await self._patch_message_once(message_id, content) except asyncio.CancelledError: raise @@ -1115,6 +1240,7 @@ async def _fetch_message_text(self, message_id: str) -> str: if self._client is None: return "" try: + _provider_segment(message_id, "Feishu message_id") token = await self._get_access_token() response = await self._client.get( f"{self._domain}/open-apis/im/v1/messages/{message_id}", @@ -1138,11 +1264,13 @@ def _resolve_receive(self, recipient: str) -> tuple[str, str]: value = recipient.strip() if value.startswith(f"{_CHANNEL}:"): value = value[len(_CHANNEL) + 1 :] + _provider_segment(value, "Feishu recipient") if value.startswith("oc_"): return value, "chat_id" if self._identity is not None: resolved = self._identity.resolve(value) if resolved: + _provider_segment(resolved, "Feishu resolved recipient") return resolved, "chat_id" if value.startswith("ou_"): return value, "open_id" @@ -1290,6 +1418,51 @@ def _allow_from(config: Mapping[str, object]) -> frozenset[str]: return frozenset(item.strip() for item in value if isinstance(item, str) and item.strip()) +def _provider_segment(value: object, field_name: str) -> str: + """Validate an opaque provider value before putting it in a path or request.""" + + if not isinstance(value, str) or not value: + raise ValueError(f"{field_name} 不能为空") + if value != value.strip(): + raise ValueError(f"{field_name} 不能包含首尾空白") + if len(value) > _MAX_PROVIDER_SEGMENT_LENGTH: + raise ValueError(f"{field_name} 超过长度上限") + if "/" in value or "\\" in value: + raise ValueError(f"{field_name} 不能包含路径分隔符") + if any(ord(char) < 32 or ord(char) == 127 for char in value): + raise ValueError(f"{field_name} 不能包含控制字符") + return value + + +def _check_inbound_batch(data: tuple[bytes, ...] | list[bytes]) -> None: + """Enforce inbound attachment count and aggregate byte limits before import.""" + + if len(data) > _MAX_ATTACHMENT_COUNT: + raise ValueError("Feishu 入站附件数量超过上限") + total = 0 + for item in data: + if not isinstance(item, bytes): + raise TypeError("Feishu 入站附件必须是 bytes") + total += len(item) + if total > _MAX_ATTACHMENT_BATCH_BYTES: + raise ValueError("Feishu 入站附件批次超过总大小上限") + + +def _stop_candidate(message: Any) -> tuple[str, bool]: + """Extract control text and media presence without downloading provider bytes.""" + + message_type = str(getattr(message, "message_type", "") or "") + content = str(getattr(message, "content", "") or "") + if message_type == "text": + return _extract_text(content), False + if message_type in {"image", "file"}: + return "", True + if message_type == "post": + text, image_keys = _extract_post(content) + return text, bool(image_keys) + return "", False + + def _message_timestamp(message: Any) -> datetime: raw = getattr(message, "create_time", None) try: @@ -1430,9 +1603,26 @@ def _feishu_error_status(error: BaseException) -> DeliveryStatus: if error.code in _RATE_LIMIT_CODES else DeliveryStatus.REJECTED ) + if isinstance(error, httpx.RequestError) and _is_pre_effect_request_error(error): + return DeliveryStatus.REJECTED return DeliveryStatus.UNKNOWN +def _is_pre_effect_request_error(error: httpx.RequestError) -> bool: + """Classify connection setup failures that cannot have reached Feishu.""" + + return isinstance( + error, + ( + httpx.ConnectError, + httpx.ConnectTimeout, + httpx.ProxyError, + httpx.UnsupportedProtocol, + httpx.InvalidURL, + ), + ) + + async def _close_attachment_lease(lease: Any) -> None: """Finish attachment lease cleanup even when the caller is cancelled.""" @@ -1458,19 +1648,14 @@ async def _bounded_response_bytes(response: Any) -> bytes: chunks: list[bytes] = [] total = 0 aiter_bytes = getattr(response, "aiter_bytes", None) - if callable(aiter_bytes): - aiter_bytes = cast(Callable[[], AsyncIterable[bytes]], aiter_bytes) - async for chunk in aiter_bytes(): - if not isinstance(chunk, bytes): - raise TypeError("Feishu provider response chunk 必须是 bytes") - total += len(chunk) - if total > _MAX_ATTACHMENT_BYTES: - raise ValueError("Feishu 入站附件超过大小上限") - chunks.append(chunk) - return b"".join(chunks) - data = response.content - if not isinstance(data, bytes): - raise TypeError("Feishu provider response content 必须是 bytes") - if len(data) > _MAX_ATTACHMENT_BYTES: - raise ValueError("Feishu 入站附件超过大小上限") - return data + if not callable(aiter_bytes): + raise TypeError("Feishu provider response 必须提供 aiter_bytes") + aiter_bytes = cast(Callable[[], AsyncIterable[bytes]], aiter_bytes) + async for chunk in aiter_bytes(): + if not isinstance(chunk, bytes): + raise TypeError("Feishu provider response chunk 必须是 bytes") + total += len(chunk) + if total > _MAX_ATTACHMENT_BYTES: + raise ValueError("Feishu 入站附件超过大小上限") + chunks.append(chunk) + return b"".join(chunks) diff --git a/tests/test_plugin.py b/tests/test_plugin.py index 6df9998..f4b90ce 100644 --- a/tests/test_plugin.py +++ b/tests/test_plugin.py @@ -3,12 +3,14 @@ import asyncio import hashlib import importlib.util +import json import sys import threading from pathlib import Path from types import SimpleNamespace import pytest +import httpx from agent.plugin_composition.channels import ( AttachmentKind, @@ -683,6 +685,175 @@ async def uncertain(recipient: str, message_type: str, content: str): await adapter.stop() +@pytest.mark.asyncio +async def test_delivery_after_prior_success_aggregates_later_rejection_as_unknown() -> None: + adapter = module.build_feishu_channel(_context()) + adapter._client = object() + first = AttachmentRef( + "aggregate-1", + AttachmentKind.FILE, + "a.txt", + "text/plain", + 1, + hashlib.sha256(b"a").hexdigest(), + ) + second = AttachmentRef( + "aggregate-2", + AttachmentKind.FILE, + "b.txt", + "text/plain", + 1, + hashlib.sha256(b"b").hexdigest(), + ) + + async def read(_refs): + return [(first, b"a"), (second, b"b")] + + outcomes = iter( + [ + (DeliveryStatus.DELIVERED, "provider-1", None), + (DeliveryStatus.REJECTED, None, "HTTP 400"), + ] + ) + + async def send(_recipient, _ref, _data): + return next(outcomes) + + adapter._read_attachments = read + adapter._send_attachment = send + receipt = await adapter.deliver( + ProviderDeliveryRequest( + "binding-1", "aggregate-delivery", "oc_chat", "", (first, second) + ) + ) + assert receipt.status is DeliveryStatus.UNKNOWN + assert receipt.provider_ids == ("provider-1",) + + +@pytest.mark.asyncio +async def test_connection_setup_error_is_rejected_before_any_feishu_effect() -> None: + adapter = module.build_feishu_channel(_context()) + adapter._client = object() + + async def fail_connect(*_args, **_kwargs): + raise httpx.ConnectError("connect failed") + + adapter._post_message_once = fail_connect + receipt = await adapter.deliver( + ProviderDeliveryRequest("binding-1", "connect-error", "oc_chat", "hello") + ) + assert receipt.status is DeliveryStatus.REJECTED + + +@pytest.mark.asyncio +async def test_inbound_download_streams_and_enforces_bound_before_import( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(module.channel, "_MAX_ATTACHMENT_BYTES", 3) + adapter = module.build_feishu_channel(_context()) + calls: list[tuple[str, str]] = [] + + class Response: + def raise_for_status(self) -> None: + return None + + async def aiter_bytes(self): + yield b"xx" + yield b"xx" + + class Stream: + async def __aenter__(self): + return Response() + + async def __aexit__(self, *_args): + return None + + class Client: + def stream(self, method: str, url: str, **kwargs): + calls.append((method, url)) + assert kwargs["follow_redirects"] is False + return Stream() + + async def token() -> str: + return "token" + + adapter._client = Client() + adapter._get_access_token = token + with pytest.raises(ValueError, match="超过大小上限"): + await adapter._download_resource_bytes("message-1", "file-1", "file") + assert calls == [ + ( + "GET", + "https://example.test/open-apis/im/v1/messages/message-1/resources/file-1", + ) + ] + + +@pytest.mark.asyncio +async def test_runtime_lifecycle_rejects_before_open_and_stop_drains_accepted_task() -> None: + adapter = module.build_feishu_channel(_context()) + context = adapter._context + adapter.attach_runtime(SimpleNamespace(binding_token=context.binding_token)) + adapter.open_admission() + adapter.close_admission() + assert not adapter._admission_open + + released = asyncio.Event() + + async def accepted_before_close() -> None: + await released.wait() + + task = asyncio.create_task(accepted_before_close()) + adapter._inbound_tasks.add(task) + task.add_done_callback(adapter._inbound_tasks.discard) + stopping = asyncio.create_task(adapter.stop()) + await asyncio.sleep(0) + assert not stopping.done() + released.set() + assert (await stopping).resources_closed + + +@pytest.mark.asyncio +async def test_feishu_stop_with_post_attachment_rejects_before_import() -> None: + control = FakeControl() + imported = FakeAttachmentImport() + adapter = module.build_feishu_channel( + _context(control=control, attachment_import=imported) + ) + message = _message( + message_type="post", + content=json.dumps( + { + "title": "/stop", + "content": [[{"tag": "img", "image_key": "image-key"}]], + } + ), + ) + + async def fail_download(*_args): + raise AssertionError("带附件 /stop 不得下载 provider media") + + adapter._download_resource_bytes = fail_download + status = await adapter._ingest_message( + message, "stop-media", "oc_chat", "ou_sender", "", "" + ) + assert status is DeliveryStatus.REJECTED + assert imported.calls == [] + assert control.raw is None + + +@pytest.mark.asyncio +async def test_feishu_invalid_recipient_path_is_rejected_before_provider_call() -> None: + adapter = module.build_feishu_channel(_context()) + adapter._client = object() + receipt = await adapter.deliver( + ProviderDeliveryRequest( + "binding-1", "invalid-recipient", "oc_chat/escape", "hello" + ) + ) + assert receipt.status is DeliveryStatus.REJECTED + + @pytest.mark.asyncio async def test_text_and_image_inbound_import_core_attachment_before_admission() -> None: ingress = FakeIngress() @@ -759,6 +930,7 @@ async def test_legacy_allow_from_alias_reaches_inbound_allowlist() -> None: adapter.attach_presentation( ChannelPresentationPorts(control=FakeControl(), turn_stream=stream) ) + adapter.open_admission() status = await adapter._handle_message_event(_event()) From b69340439640d82a965b46243eb8565108804a56 Mon Sep 17 00:00:00 2001 From: huashen <2494946808@qq.com> Date: Thu, 20 Aug 2026 15:43:45 +0800 Subject: [PATCH 09/11] fix(feishu): secure provider and batch effect boundaries --- channel.py | 106 +++++++++++++++++++++++--------- tests/test_plugin.py | 142 ++++++++++++++++++++++++++++++++++++++----- 2 files changed, 205 insertions(+), 43 deletions(-) diff --git a/channel.py b/channel.py index 4edc16d..1dd0756 100644 --- a/channel.py +++ b/channel.py @@ -14,6 +14,7 @@ import threading import time import warnings +from urllib.parse import urlsplit from collections.abc import AsyncIterable, Callable, Coroutine, Mapping from dataclasses import dataclass from datetime import datetime, timezone @@ -61,7 +62,8 @@ _WS_RECONNECT_DELAY_S = 5.0 _WS_STOP_TIMEOUT_S = 2.0 _REJECTED_HTTP_STATUSES = frozenset({400, 401, 403, 404, 405, 413, 415, 422}) -_RATE_LIMIT_CODES = frozenset({99991400, 99991661, 230020, 230027, 11232}) +_REJECTED_BUSINESS_CODES: frozenset[int] = frozenset() +_FEISHU_API_HOSTS = frozenset({"open.feishu.cn", "open.larksuite.com"}) _CREDENTIAL_ALIASES = { "app_id": ("appId", "app_id"), "app_secret": ("appSecret", "app_secret"), @@ -211,7 +213,7 @@ async def start(self) -> ChannelReady: self._provider_client = await self._provider_factory.create(self._credentials) self._read_credential("app_id") self._read_credential("app_secret") - self._client = httpx.AsyncClient(timeout=30.0) + self._client = httpx.AsyncClient(timeout=30.0, follow_redirects=False) # 2. Subscribe through the exact Core stream and keep admission closed. turn_stream = self._presentation.turn_stream @@ -381,9 +383,7 @@ async def _send_attachment( try: provider_key = await self._upload_attachment(ref, data) except asyncio.CancelledError: - raise - except ValueError as error: - return DeliveryStatus.REJECTED, None, str(error) + return DeliveryStatus.UNKNOWN, None, "Feishu attachment upload 被取消,效果未知" except Exception as error: return _feishu_error_status(error), None, str(error) or type(error).__name__ try: @@ -398,7 +398,7 @@ async def _send_attachment( ), ) except asyncio.CancelledError: - raise + return DeliveryStatus.UNKNOWN, None, "Feishu attachment send 被取消,效果未知" except Exception as error: # The upload already changed provider state; a send failure is not a # deterministic no-effect rejection even when the HTTP status is 4xx. @@ -802,7 +802,12 @@ async def _extract_inbound_payload( return _extract_text(content_raw), [] if message_type == "image": image_key = _extract_key(content_raw, "image_key") - data = await self._download_resource_bytes(message_id, image_key, "image") + data = await self._download_resource_bytes( + message_id, + image_key, + "image", + max_bytes=min(_MAX_ATTACHMENT_BYTES, _MAX_ATTACHMENT_BATCH_BYTES), + ) _check_inbound_batch((data,)) return "[图片]", [ await self._import_inbound_attachment( @@ -815,7 +820,12 @@ async def _extract_inbound_payload( if message_type == "file": file_name = _extract_key(content_raw, "file_name") or "file" file_key = _extract_key(content_raw, "file_key") - data = await self._download_resource_bytes(message_id, file_key, "file") + data = await self._download_resource_bytes( + message_id, + file_key, + "file", + max_bytes=min(_MAX_ATTACHMENT_BYTES, _MAX_ATTACHMENT_BATCH_BYTES), + ) _check_inbound_batch((data,)) media_type = mimetypes.guess_type(file_name)[0] or "application/octet-stream" return f"[文件: {file_name}]", [ @@ -832,9 +842,18 @@ async def _extract_inbound_payload( raise ValueError("Feishu 入站附件数量超过上限") attachments: list[AttachmentRef] = [] downloaded: list[bytes] = [] + remaining = _MAX_ATTACHMENT_BATCH_BYTES for index, image_key in enumerate(image_keys, start=1): - data = await self._download_resource_bytes(message_id, image_key, "image") + if remaining <= 0: + raise ValueError("Feishu 入站附件批次超过总大小上限") + data = await self._download_resource_bytes( + message_id, + image_key, + "image", + max_bytes=min(_MAX_ATTACHMENT_BYTES, remaining), + ) downloaded.append(data) + remaining -= len(data) _check_inbound_batch(downloaded) for index, data in enumerate(downloaded, start=1): attachments.append( @@ -854,11 +873,15 @@ async def _download_resource_bytes( message_id: str, file_key: str, resource_type: str, + *, + max_bytes: int, ) -> bytes: """Download one provider resource with a fixed memory bound.""" _provider_segment(message_id, "Feishu message_id") _provider_segment(file_key, "Feishu file_key") + if max_bytes <= 0 or max_bytes > _MAX_ATTACHMENT_BYTES: + raise ValueError("Feishu 入站附件读取额度非法") if self._client is None: raise RuntimeError("Feishu HTTP client 尚未 start") token = await self._get_access_token() @@ -870,7 +893,7 @@ async def _download_resource_bytes( follow_redirects=False, ) as response: response.raise_for_status() - return await _bounded_response_bytes(response) + return await _bounded_response_bytes(response, max_bytes=max_bytes) async def _import_inbound_attachment( self, @@ -1122,21 +1145,23 @@ async def _send_one( message_type: str, content: str, ) -> tuple[DeliveryStatus, str | None, str | None]: + try: + self._resolve_receive(recipient) + except ValueError as error: + return DeliveryStatus.REJECTED, None, str(error) try: payload = await self._post_message_once(recipient, message_type, content) except asyncio.CancelledError: - raise + return DeliveryStatus.UNKNOWN, None, "Feishu provider send 被取消,效果未知" except ValueError as error: - return DeliveryStatus.REJECTED, None, str(error) + return DeliveryStatus.UNKNOWN, None, str(error) except httpx.HTTPStatusError as error: status = error.response.status_code if status in _REJECTED_HTTP_STATUSES: return DeliveryStatus.REJECTED, None, f"HTTP {status}" return DeliveryStatus.UNKNOWN, None, f"HTTP {status}" except FeishuApiError as error: - if error.code in _RATE_LIMIT_CODES: - return DeliveryStatus.UNKNOWN, None, str(error) - return DeliveryStatus.REJECTED, None, str(error) + return _feishu_error_status(error), None, str(error) except httpx.RequestError as error: return _feishu_error_status(error), None, str(error) or type(error).__name__ except Exception as error: @@ -1159,16 +1184,14 @@ async def _patch_one( _provider_segment(message_id, "Feishu message_id") await self._patch_message_once(message_id, content) except asyncio.CancelledError: - raise + return DeliveryStatus.UNKNOWN, "Feishu provider patch 被取消,效果未知" except httpx.HTTPStatusError as error: status = error.response.status_code if status in _REJECTED_HTTP_STATUSES: return DeliveryStatus.REJECTED, f"HTTP {status}" return DeliveryStatus.UNKNOWN, f"HTTP {status}" except FeishuApiError as error: - if error.code in _RATE_LIMIT_CODES: - return DeliveryStatus.UNKNOWN, str(error) - return DeliveryStatus.REJECTED, str(error) + return _feishu_error_status(error), str(error) except Exception as error: return DeliveryStatus.UNKNOWN, str(error) or type(error).__name__ return DeliveryStatus.DELIVERED, None @@ -1403,10 +1426,25 @@ async def _close_resources_after_start_failure(self) -> tuple[BaseException, ... def _domain(config: Mapping[str, object]) -> str: + """Validate the sole owner of every credential-bearing Feishu API URL.""" + value = config.get("domain", "https://open.feishu.cn") - if not isinstance(value, str) or not value.strip(): - return "https://open.feishu.cn" - return value.rstrip("/") + if not isinstance(value, str) or value != value.strip() or not value: + raise ValueError("Feishu domain 必须是非空 HTTPS URL") + parsed = urlsplit(value) + if parsed.scheme.lower() != "https" or parsed.hostname not in _FEISHU_API_HOSTS: + raise ValueError("Feishu domain 必须是官方 HTTPS API 域名") + if parsed.username is not None or parsed.password is not None: + raise ValueError("Feishu domain 禁止 userinfo") + try: + port = parsed.port + except ValueError as error: + raise ValueError("Feishu domain 端口非法") from error + if port not in (None, 443): + raise ValueError("Feishu domain 只允许默认 HTTPS 端口") + if parsed.path not in ("", "/") or parsed.query or parsed.fragment: + raise ValueError("Feishu domain 禁止 path/query/fragment") + return f"https://{parsed.hostname}" def _allow_from(config: Mapping[str, object]) -> frozenset[str]: @@ -1599,9 +1637,9 @@ def _feishu_error_status(error: BaseException) -> DeliveryStatus: ) if isinstance(error, FeishuApiError): return ( - DeliveryStatus.UNKNOWN - if error.code in _RATE_LIMIT_CODES - else DeliveryStatus.REJECTED + DeliveryStatus.REJECTED + if error.code in _REJECTED_BUSINESS_CODES + else DeliveryStatus.UNKNOWN ) if isinstance(error, httpx.RequestError) and _is_pre_effect_request_error(error): return DeliveryStatus.REJECTED @@ -1642,9 +1680,21 @@ async def _close_attachment_lease(lease: Any) -> None: return result -async def _bounded_response_bytes(response: Any) -> bytes: +async def _bounded_response_bytes(response: Any, *, max_bytes: int) -> bytes: """Collect a provider response without exceeding the attachment memory bound.""" + if max_bytes <= 0 or max_bytes > _MAX_ATTACHMENT_BYTES: + raise ValueError("Feishu 入站附件读取额度非法") + headers = getattr(response, "headers", None) + if headers is not None: + raw_length = headers.get("content-length") + if raw_length is not None: + try: + content_length = int(raw_length) + except (TypeError, ValueError) as error: + raise ValueError("Feishu provider Content-Length 非法") from error + if content_length < 0 or content_length > max_bytes: + raise ValueError("Feishu 入站附件超过剩余批次额度") chunks: list[bytes] = [] total = 0 aiter_bytes = getattr(response, "aiter_bytes", None) @@ -1655,7 +1705,7 @@ async def _bounded_response_bytes(response: Any) -> bytes: if not isinstance(chunk, bytes): raise TypeError("Feishu provider response chunk 必须是 bytes") total += len(chunk) - if total > _MAX_ATTACHMENT_BYTES: - raise ValueError("Feishu 入站附件超过大小上限") + if total > max_bytes: + raise ValueError("Feishu 入站附件超过剩余批次额度") chunks.append(chunk) return b"".join(chunks) diff --git a/tests/test_plugin.py b/tests/test_plugin.py index f4b90ce..77c265e 100644 --- a/tests/test_plugin.py +++ b/tests/test_plugin.py @@ -227,7 +227,7 @@ def _context( config=( config if config is not None - else {"allow_from": ("ou_sender",), "domain": "https://example.test"} + else {"allow_from": ("ou_sender",), "domain": "https://open.feishu.cn"} ), credentials={ "appId": CredentialRef(("appId",)), @@ -576,7 +576,7 @@ async def upload(data: bytes, filename: str): @pytest.mark.asyncio -async def test_attachment_upload_failure_is_rejected_without_attachment_message() -> None: +async def test_attachment_upload_business_error_is_unknown_without_attachment_message() -> None: stream = FakeTurnStream() data = b"x" attachment = AttachmentRef( @@ -610,13 +610,13 @@ async def upload(data: bytes, filename: str): receipt = await adapter.deliver( ProviderDeliveryRequest("binding-1", "delivery-failure", "oc_chat", "", (attachment,)) ) - assert receipt.status is DeliveryStatus.REJECTED + assert receipt.status is DeliveryStatus.UNKNOWN assert calls == [] await adapter.stop() @pytest.mark.asyncio -async def test_attachment_delivery_propagates_cancel_and_closes_read_lease() -> None: +async def test_attachment_delivery_cancel_settles_unknown_and_closes_read_lease() -> None: stream = FakeTurnStream() data = b"x" attachment = AttachmentRef( @@ -632,15 +632,24 @@ async def test_attachment_delivery_propagates_cancel_and_closes_read_lease() -> adapter._run_ws_client = lambda: adapter._ws_stopped.wait() adapter.attach_presentation(ChannelPresentationPorts(FakeControl(), stream)) await adapter.start() + started = asyncio.Event() async def upload(data: bytes, filename: str): - raise asyncio.CancelledError + started.set() + await asyncio.Event().wait() adapter._upload_file = upload - with pytest.raises(asyncio.CancelledError): - await adapter.deliver( - ProviderDeliveryRequest("binding-1", "delivery-cancel", "oc_chat", "", (attachment,)) + task = asyncio.create_task( + adapter.deliver( + ProviderDeliveryRequest( + "binding-1", "delivery-cancel", "oc_chat", "", (attachment,) + ) ) + ) + await started.wait() + task.cancel() + receipt = await task + assert receipt.status is DeliveryStatus.UNKNOWN assert read.leases[0].closed await adapter.stop() @@ -660,7 +669,9 @@ async def test_delivery_fallback_only_runs_after_deterministic_card_rejection() async def rejected_card(recipient: str, message_type: str, content: str): calls.append(message_type) if message_type == "interactive": - raise module.channel.FeishuApiError(123, "card rejected") + request = httpx.Request("POST", "https://open.feishu.cn/open-apis/im/v1/messages") + response = httpx.Response(400, request=request) + raise httpx.HTTPStatusError("card rejected", request=request, response=response) return {"message_id": "text-fallback"} adapter._post_message_once = rejected_card @@ -672,6 +683,19 @@ async def rejected_card(recipient: str, message_type: str, content: str): calls.clear() + async def unknown_business_code(recipient: str, message_type: str, content: str): + calls.append(message_type) + raise module.channel.FeishuApiError(123, "provider effect unspecified") + + adapter._post_message_once = unknown_business_code + business_unknown = await adapter.deliver( + ProviderDeliveryRequest("binding-1", "delivery-business", "oc_chat", "hello") + ) + assert business_unknown.status is DeliveryStatus.UNKNOWN + assert calls == ["interactive"] + + calls.clear() + async def uncertain(recipient: str, message_type: str, content: str): calls.append(message_type) raise TimeoutError("provider effect unknown") @@ -779,16 +803,94 @@ async def token() -> str: adapter._client = Client() adapter._get_access_token = token - with pytest.raises(ValueError, match="超过大小上限"): - await adapter._download_resource_bytes("message-1", "file-1", "file") + with pytest.raises(ValueError, match="超过剩余批次额度"): + await adapter._download_resource_bytes( + "message-1", "file-1", "file", max_bytes=3 + ) assert calls == [ ( "GET", - "https://example.test/open-apis/im/v1/messages/message-1/resources/file-1", + "https://open.feishu.cn/open-apis/im/v1/messages/message-1/resources/file-1", ) ] +def test_malicious_domain_is_rejected_before_credentials_or_http() -> None: + factory = FakeProviderFactory() + context = _context( + factory=factory, + config={ + "allow_from": ("ou_sender",), + "domain": "https://open.feishu.cn@evil.example/steal", + }, + ) + with pytest.raises(ValueError, match="官方 HTTPS API 域名"): + module.build_feishu_channel(context) + assert factory.create_calls == 0 + assert factory.client.requested == [] + + +@pytest.mark.asyncio +async def test_post_batch_applies_remaining_budget_before_second_stream_read( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(module.channel, "_MAX_ATTACHMENT_BATCH_BYTES", 3) + imported = FakeAttachmentImport() + adapter = module.build_feishu_channel(_context(attachment_import=imported)) + streamed: list[tuple[str, bool]] = [] + + class Response: + def __init__(self, key: str) -> None: + self.key = key + self.headers = {"content-length": "2"} + + def raise_for_status(self) -> None: + return None + + async def aiter_bytes(self): + streamed.append((self.key, True)) + yield b"xx" + + class Stream: + def __init__(self, key: str) -> None: + self.response = Response(key) + + async def __aenter__(self) -> Response: + return self.response + + async def __aexit__(self, *_args) -> None: + return None + + class Client: + def stream(self, _method: str, url: str, **_kwargs) -> Stream: + key = url.rsplit("/", 1)[-1] + streamed.append((key, False)) + return Stream(key) + + async def token() -> str: + return "token" + + adapter._client = Client() + adapter._get_access_token = token + message = _message( + message_type="post", + content=json.dumps( + { + "content": [ + [ + {"tag": "img", "image_key": "first"}, + {"tag": "img", "image_key": "second"}, + ] + ] + } + ), + ) + with pytest.raises(ValueError, match="剩余批次额度"): + await adapter._extract_inbound_payload(message, "message-1") + assert streamed == [("first", False), ("first", True), ("second", False)] + assert imported.calls == [] + + @pytest.mark.asyncio async def test_runtime_lifecycle_rejects_before_open_and_stop_drains_accepted_task() -> None: adapter = module.build_feishu_channel(_context()) @@ -869,8 +971,18 @@ async def test_text_and_image_inbound_import_core_attachment_before_admission() assert ingress.raw[0].provider_identity == "ou_sender" assert ingress.raw[0].recipient == "oc_chat" assert ingress.raw[0].message.content == "hello" - async def download(message_id: str, file_key: str, resource_type: str) -> bytes: + async def download( + message_id: str, + file_key: str, + resource_type: str, + *, + max_bytes: int, + ) -> bytes: assert (message_id, file_key, resource_type) == ("msg-2", "img", "image") + assert max_bytes == min( + module.channel._MAX_ATTACHMENT_BYTES, + module.channel._MAX_ATTACHMENT_BATCH_BYTES, + ) return b"image-bytes" adapter._download_resource_bytes = download @@ -898,7 +1010,7 @@ async def test_unauthorized_inbound_and_control_are_fail_closed() -> None: ingress=ingress, control=control, stream=stream, - config={"allowFrom": (), "domain": "https://example.test"}, + config={"allowFrom": (), "domain": "https://open.feishu.cn"}, ) ) adapter.attach_presentation( @@ -924,7 +1036,7 @@ async def test_legacy_allow_from_alias_reaches_inbound_allowlist() -> None: _context( ingress=ingress, stream=stream, - config={"allowFrom": ("ou_sender",), "domain": "https://example.test"}, + config={"allowFrom": ("ou_sender",), "domain": "https://open.feishu.cn"}, ) ) adapter.attach_presentation( From ab65570c3d514fbbe5284ecc5952c0113321d71c Mon Sep 17 00:00:00 2001 From: huashen <2494946808@qq.com> Date: Sat, 22 Aug 2026 01:18:10 +0800 Subject: [PATCH 10/11] ci(plugin): align v3 gate with final core --- .github/workflows/plugin-api-v3.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/plugin-api-v3.yml b/.github/workflows/plugin-api-v3.yml index 798fc76..ee7b94d 100644 --- a/.github/workflows/plugin-api-v3.yml +++ b/.github/workflows/plugin-api-v3.yml @@ -36,7 +36,7 @@ jobs: - uses: actions/checkout@v4 with: repository: kachofugetsu09/akashic-agent - ref: b97f919b1fd865d23d11095cbc63d2354803bad9 + ref: 3005f838bcd96e2cbc58616aede46e4f39df4523 path: .akashic-core - uses: actions/setup-python@v5 with: @@ -59,7 +59,7 @@ jobs: - name: Check v3 source types env: PYTHONPATH: .akashic-core - run: .venv/bin/basedpyright --level error plugin.py channel.py config.py cards.py tests + run: .venv/bin/pyright --level error plugin.py channel.py config.py cards.py tests - name: Compile Python sources run: python -m compileall -q plugin.py channel.py config.py cards.py tests - name: Check diff formatting From ab3bfb782c750c2298d721bfa3e34d066aefae82 Mon Sep 17 00:00:00 2001 From: huashen <2494946808@qq.com> Date: Sat, 22 Aug 2026 01:22:15 +0800 Subject: [PATCH 11/11] ci(plugin): bind type checks to the v3 runtime --- .github/workflows/plugin-api-v3.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/plugin-api-v3.yml b/.github/workflows/plugin-api-v3.yml index ee7b94d..539876d 100644 --- a/.github/workflows/plugin-api-v3.yml +++ b/.github/workflows/plugin-api-v3.yml @@ -59,7 +59,7 @@ jobs: - name: Check v3 source types env: PYTHONPATH: .akashic-core - run: .venv/bin/pyright --level error plugin.py channel.py config.py cards.py tests + run: .venv/bin/pyright --pythonpath .venv/bin/python --level error plugin.py channel.py config.py cards.py tests - name: Compile Python sources run: python -m compileall -q plugin.py channel.py config.py cards.py tests - name: Check diff formatting