feat(sdk): expose Feishu event long connection to plugins

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
InfinityPacer
2026-09-30 15:18:53 +08:00
committed by InfinityPacer
parent 47868dbace
commit 4847813ca7
13 changed files with 73 additions and 34 deletions

View File

@@ -7,6 +7,8 @@
整个连接在一个线程内同步运行:阻塞 recv 以心跳间隔为超时,超时即发送 ping,因此收发都在
同一线程,无需发送锁或事件循环。
宿主飞书模块与插件共用本实现,插件经 ``app.sdk.feishu`` 获取,不必再依赖 lark-oapi。
"""
import base64
@@ -23,7 +25,7 @@ import websocket
from app.adapters.network.http import RequestUtils
from app.runtime.log import logger
# 本模块不引用同包的 openapi,避免子模块经父包形成导入环;域名默认值与其保持一致。
# 适配层不反向引用飞书模块的 openapi;域名默认值与 app.modules.feishu.openapi 保持一致。
FEISHU_DOMAIN = "https://open.feishu.cn"
# 事件报文与应答结果的 JSON 结构。
JsonDict = Dict[str, Any]

View File

@@ -7,12 +7,12 @@ from pathlib import Path
from typing import Any, Callable, Dict, List, Optional, Tuple, Union
from urllib.parse import urlparse
from app.adapters.network.feishu import FeishuLongConnection
from app.adapters.network.http import RequestUtils
from app.application.messaging.channel.admin import matches_channel_admin
from app.application.messaging.ingress import submit_message_to_host
from app.application.security.user import get_configured_user_channel_lookup
from app.domain.context import Context, MediaInfo
from app.modules.feishu.longconn import FeishuLongConnection
from app.modules.feishu.openapi import FEISHU_DOMAIN, FeishuOpenApi, JsonDict
from app.runtime.log import logger
from app.runtime.settings import get_runtime_setting

5
app/sdk/feishu.py Normal file
View File

@@ -0,0 +1,5 @@
"""插件可依赖的飞书事件长连接接口,与宿主飞书模块共用同一传输实现。"""
from app.adapters.network.feishu import FEISHU_DOMAIN, FatalConnectionError, FeishuLongConnection
__all__ = ["FEISHU_DOMAIN", "FatalConnectionError", "FeishuLongConnection"]

View File

@@ -763,7 +763,7 @@ flowchart LR
| 指标 | 当前值 |
|---|---:|
| Python 模块 | 1060 |
| 内部导入边 | 9,057 |
| 内部导入边 | 9,060 |
| 非平凡 SCC | 1(精确 containment 的 TMDB 移植包环) |
| Application / Chain 具体 Adapter 直连 | 0 / 0 |
| Direct egress | 48(债务已清零,48 条精确 containment) |
@@ -773,7 +773,7 @@ flowchart LR
| Model/Oper 自动事务与自建 Session | 0 |
| 组合根外 `SystemConfigOper()` | 0 |
整理失败反馈由 `app.application.transfer.feedback` 集中投影;已整理下载的站点字幕复用 `app.chain.transfer` 自动整理队列;网络连通性检测按 `app.application.nettest` 归档,并覆盖 HTTP 与 WebSocket 探测。未启用智能助手的消息音乐交互由 `app.chain.music_interaction` 包中的解析展示与订阅协调模块处理;音乐标签准入与批次共识由 `app.chain.transfer.music` 负责,预览证据由 `app.application.transfer.projection` 只读投影;宿主依赖边现为 9,057,音乐交互包未新增 SCC,插件一次性任务 owner `app.scheduler.oncejob` 带来 1 个模块和 9 条依赖边。Agent 持久回执新增 Application 端口及 DB Model/Oper/Adapter 四个冷导入模块。缓存组合根只在启动时选用 Redis 缓存才导入 Redis 适配器,依赖 langchain_core 的对话记忆模型归属 `app.agent.memory`,文件缓存且未启用智能体的实例不再加载 redis 与 langchain_core。Telegram 渠道改用模块内 `app.modules.telegram.botapi` 精简 Bot API 客户端,经 `RequestUtils` 出站,宿主不再导入 pyTelegramBotAPI,启用 Telegram 的实例也不会因该 SDK 加载 redis;该依赖暂时保留给插件使用。Web Push 协议(aes128gcm 加密、VAPID 签名与经 `RequestUtils` 投递)由网络适配器 `app.adapters.network.webpush` 直接基于 http-ece 与 py-vapid 实现,消息端点与 `WebPushModule` 共用,宿主不再导入 pywebpush 及其连带的 aiohttp;该依赖同样暂时保留给插件使用。飞书渠道改用模块内 `app.modules.feishu.openapi` OpenAPI 客户端(经 `RequestUtils` 出站)与 `app.modules.feishu.longconn` 事件长连接(websocket-client 同步收发 pbbp2 帧),宿主不再导入 lark-oapi 及其上万个生成模块,长连接也不再依赖 SDK 的模块级事件循环;lark-oapi 暂时保留给插件使用。当前 `app.startup.lifecycle` 为 576、`app.factory` 为 588、`app.main` 为 590。音乐调用观察 `app.application.music.observation` 新增一个轻量模块,记录来源结果及请求预算;AcoustID及路径识别复用此上下文传递有界指纹候选,保留旧单ID模块合同。站点图片域名快照新增 `app.application.security.image`,各启动入口增加一个模块;性能基线只同步模块数量,原有耗时预算、历史采样和生命周期资源约束保持有效。
整理失败反馈由 `app.application.transfer.feedback` 集中投影;已整理下载的站点字幕复用 `app.chain.transfer` 自动整理队列;网络连通性检测按 `app.application.nettest` 归档,并覆盖 HTTP 与 WebSocket 探测。未启用智能助手的消息音乐交互由 `app.chain.music_interaction` 包中的解析展示与订阅协调模块处理;音乐标签准入与批次共识由 `app.chain.transfer.music` 负责,预览证据由 `app.application.transfer.projection` 只读投影;宿主依赖边现为 9,060,音乐交互包未新增 SCC,插件一次性任务 owner `app.scheduler.oncejob` 带来 1 个模块和 9 条依赖边。Agent 持久回执新增 Application 端口及 DB Model/Oper/Adapter 四个冷导入模块。缓存组合根只在启动时选用 Redis 缓存才导入 Redis 适配器,依赖 langchain_core 的对话记忆模型归属 `app.agent.memory`,文件缓存且未启用智能体的实例不再加载 redis 与 langchain_core。Telegram 渠道改用模块内 `app.modules.telegram.botapi` 精简 Bot API 客户端,经 `RequestUtils` 出站,宿主不再导入 pyTelegramBotAPI,启用 Telegram 的实例也不会因该 SDK 加载 redis;该依赖暂时保留给插件使用。Web Push 协议(aes128gcm 加密、VAPID 签名与经 `RequestUtils` 投递)由网络适配器 `app.adapters.network.webpush` 直接基于 http-ece 与 py-vapid 实现,消息端点与 `WebPushModule` 共用,宿主不再导入 pywebpush 及其连带的 aiohttp;该依赖同样暂时保留给插件使用。飞书渠道改用模块内 `app.modules.feishu.openapi` OpenAPI 客户端(经 `RequestUtils` 出站)与网络适配器 `app.adapters.network.feishu` 事件长连接(websocket-client 同步收发 pbbp2 帧),宿主不再导入 lark-oapi 及其上万个生成模块,长连接也不再依赖 SDK 的模块级事件循环;插件经 `app.sdk.feishu` 复用同一长连接,lark-oapi 暂时保留给尚未迁移的插件使用。当前 `app.startup.lifecycle` 为 576、`app.factory` 为 588、`app.main` 为 590。音乐调用观察 `app.application.music.observation` 新增一个轻量模块,记录来源结果及请求预算;AcoustID及路径识别复用此上下文传递有界指纹候选,保留旧单ID模块合同。站点图片域名快照新增 `app.application.security.image`,各启动入口增加一个模块;性能基线只同步模块数量,原有耗时预算、历史采样和生命周期资源约束保持有效。
架构专项验证分为两个 CI 投影:`Check event semantic policy` 先运行依赖、Adapter、出口和 Event
语义门禁,`Check host architecture snapshot` 再执行快照测试及一次

View File

@@ -96,7 +96,7 @@ ARCH-201 至 ARCH-204 均达到实现、验证、提交、推送和远端门禁
| 指标 | 当前值 | 解释 |
|---|---:|---|
| 宿主 Python 模块 / 内部依赖边 | 1060 / 9,057 | `dependency-baseline.json` 当前快照;系统设置合同按 `app.application.settings` 包组织,系统设置 API 端点独立按 `app.api.endpoints.settings` 归档;消息音乐交互由 `app.chain.music_interaction` 包中的音乐解析/展示与订阅协调模块处理;已整理下载的站点字幕经 `app.chain.transfer` 自动整理队列完成命名与归档;网络连通性检测按 `app.application.nettest` 归档 |
| 宿主 Python 模块 / 内部依赖边 | 1061 / 9,060 | `dependency-baseline.json` 当前快照;系统设置合同按 `app.application.settings` 包组织,系统设置 API 端点独立按 `app.api.endpoints.settings` 归档;消息音乐交互由 `app.chain.music_interaction` 包中的音乐解析/展示与订阅协调模块处理;已整理下载的站点字幕经 `app.chain.transfer` 自动整理队列完成命名与归档;网络连通性检测按 `app.application.nettest` 归档 |
| 非平凡 SCC | 1 | 仅保留精确 containment 的 29 模块 TMDB 移植包环 |
| 跨层 DB 边界债务 | 0 | Application、Chain、API、Agent、Runtime、Workflow 到 DB 的受控债务均为零 |
| Model/Oper 事务债务 | 0 | 自建 Session、自动事务装饰器、直接 commit/rollback 等基线均为零 |

View File

@@ -400,6 +400,7 @@ API 中允许丢失或可重建的进程内任务必须登记到 `app/runtime/ta
进入 Outbox 或持久任务表,不能把 TaskRegistry 当成 durable queue。
Runtime 关闭后不可逆;完整应用生命周期的再次启动必须由新进程承载,不能在同一解释器中重建局部资源域。
插件需要浏览器时使用 `app.sdk.browser`,由宿主浏览器适配器协调资源,不直接依赖资源实现。
插件需要飞书事件长连接时使用 `app.sdk.feishu.FeishuLongConnection`,与宿主飞书模块共用同一传输实现,不再各自依赖 lark-oapi。
旧插件若直接导入有资源前置条件的第三方包,compat 在插件 import 前递归扫描源码并保守准备资源;
无法精确解析的文件按全部已登记资源降级,最终可导入性仍由 Python loader 判断。
@@ -1214,7 +1215,7 @@ driven workflow registration.
| `app/application/security/url.py` | URL/path validation, SSRF protection and signed image policy |
| `app/application/mediaserver.py` | Configured media-server discovery and identity matching |
| `app/runtime/compat/manifest.py` | Exact legacy-to-canonical import manifest |
| `app/sdk/` | Stable plugin imports, including provider-neutral browser launch functions |
| `app/sdk/` | Stable plugin imports, including provider-neutral browser launch functions and the Feishu event long connection |
| `app/sdk/plugin/` | Plugin contract base class (`base.py`) and runtime manager facade (`manager.py`); the package root is a lazy export map so importing the contract does not load the managers |
Run `tests/test_architecture_dependencies.py` after every ownership or import

View File

@@ -162,6 +162,18 @@
"doh_query_json|call:urlopen"
]
},
{
"bindings": [
"import:websocket as websocket"
],
"fingerprint": "95aca65eed288e0c7654ab49aad6c535d9313d5e38c44b1cb1768dcdfc772453",
"kind": "raw_transport",
"source": "app.adapters.network.feishu",
"target": "websocket",
"uses": [
"FeishuLongConnection._run_once|call:create_connection"
]
},
{
"bindings": [
"import:httpx as httpx"
@@ -520,18 +532,6 @@
"target": "requests",
"uses": []
},
{
"bindings": [
"import:websocket as websocket"
],
"fingerprint": "8ccfc3b86ad029942cdf0cab9d01fa2e1498576ee59e0df7239e1a744b370eae",
"kind": "raw_transport",
"source": "app.modules.feishu.longconn",
"target": "websocket",
"uses": [
"FeishuLongConnection._run_once|call:create_connection"
]
},
{
"bindings": [
"from:smbclient.ClientConfig as ClientConfig",
@@ -930,8 +930,8 @@
"runtime_only": true
}
},
"edge_count": 9057,
"edge_sha256": "81d2d96d462b2069f5e01f6ca7f58ee2d377cb91be21c1341a57805c78204e7c",
"edge_count": 9060,
"edge_sha256": "cb948f057f3a8075ddf419370773b879f73aa13f6a13b1c0d9690f8c49f7f8a8",
"edges": [
"app -> app.foundation",
"app -> app.foundation.environment",
@@ -1041,6 +1041,11 @@
"app.adapters.network.doh -> app.runtime.log",
"app.adapters.network.doh -> app.runtime.reload",
"app.adapters.network.doh -> app.runtime.settings",
"app.adapters.network.feishu -> app.adapters",
"app.adapters.network.feishu -> app.adapters.network",
"app.adapters.network.feishu -> app.adapters.network.http",
"app.adapters.network.feishu -> app.runtime",
"app.adapters.network.feishu -> app.runtime.log",
"app.adapters.network.http -> app.runtime",
"app.adapters.network.http -> app.runtime.correlation",
"app.adapters.network.webpush -> app.adapters",
@@ -6777,6 +6782,7 @@
"app.modules.fanart -> app.schemas.types",
"app.modules.feishu.feishu -> app.adapters",
"app.modules.feishu.feishu -> app.adapters.network",
"app.modules.feishu.feishu -> app.adapters.network.feishu",
"app.modules.feishu.feishu -> app.adapters.network.http",
"app.modules.feishu.feishu -> app.application",
"app.modules.feishu.feishu -> app.application.messaging",
@@ -6789,7 +6795,6 @@
"app.modules.feishu.feishu -> app.domain.context",
"app.modules.feishu.feishu -> app.modules",
"app.modules.feishu.feishu -> app.modules.feishu",
"app.modules.feishu.feishu -> app.modules.feishu.longconn",
"app.modules.feishu.feishu -> app.modules.feishu.openapi",
"app.modules.feishu.feishu -> app.runtime",
"app.modules.feishu.feishu -> app.runtime.log",
@@ -6798,11 +6803,6 @@
"app.modules.feishu.feishu -> app.schemas",
"app.modules.feishu.feishu -> app.schemas.message",
"app.modules.feishu.feishu -> app.schemas.types",
"app.modules.feishu.longconn -> app.adapters",
"app.modules.feishu.longconn -> app.adapters.network",
"app.modules.feishu.longconn -> app.adapters.network.http",
"app.modules.feishu.longconn -> app.runtime",
"app.modules.feishu.longconn -> app.runtime.log",
"app.modules.feishu.module -> app.application",
"app.modules.feishu.module -> app.application.messaging",
"app.modules.feishu.module -> app.application.messaging.channel",
@@ -8956,6 +8956,9 @@
"app.sdk.events -> app.runtime.events",
"app.sdk.events -> app.schemas",
"app.sdk.events -> app.schemas.event",
"app.sdk.feishu -> app.adapters",
"app.sdk.feishu -> app.adapters.network",
"app.sdk.feishu -> app.adapters.network.feishu",
"app.sdk.logging -> app.runtime",
"app.sdk.logging -> app.runtime.log",
"app.sdk.media -> app.domain",
@@ -9991,7 +9994,7 @@
"app.workflow.actions.transfer_file -> app.workflow",
"app.workflow.actions.transfer_file -> app.workflow.actions"
],
"module_count": 1060,
"module_count": 1061,
"modules": [
"app",
"app.adapters",
@@ -10012,6 +10015,7 @@
"app.adapters.network.browser",
"app.adapters.network.cloudflare",
"app.adapters.network.doh",
"app.adapters.network.feishu",
"app.adapters.network.http",
"app.adapters.network.ip",
"app.adapters.network.resolver",
@@ -10673,7 +10677,6 @@
"app.modules.fanart",
"app.modules.feishu",
"app.modules.feishu.feishu",
"app.modules.feishu.longconn",
"app.modules.feishu.module",
"app.modules.feishu.openapi",
"app.modules.filemanager",
@@ -10980,6 +10983,7 @@
"app.sdk.config",
"app.sdk.database",
"app.sdk.events",
"app.sdk.feishu",
"app.sdk.logging",
"app.sdk.media",
"app.sdk.network",

View File

@@ -155,7 +155,7 @@
"reason": "产品长连接与 WSS 连通探测由协议适配器精确持有,SCGI/XML-RPC transport 由对应模块持有。",
"facts": [
{"source": "app.startup.composition.network", "target": "websocket", "kind": "raw_transport", "fingerprint": "09e1909ac750fe40b145dde8fee52eedf84aaf2123f6a1bc38109252c89d6000"},
{"source": "app.modules.feishu.longconn", "target": "websocket", "kind": "raw_transport", "fingerprint": "8ccfc3b86ad029942cdf0cab9d01fa2e1498576ee59e0df7239e1a744b370eae"},
{"source": "app.adapters.network.feishu", "target": "websocket", "kind": "raw_transport", "fingerprint": "95aca65eed288e0c7654ab49aad6c535d9313d5e38c44b1cb1768dcdfc772453"},
{"source": "app.modules.qqbot.gateway", "target": "websocket", "kind": "raw_transport", "fingerprint": "6f6b7d61f3a95e620e67c450d544f0aa077f087a188d98f32e4501d94c6b37ae"},
{"source": "app.modules.rtorrent.rtorrent", "target": "socket.create_connection", "kind": "protocol_operation", "fingerprint": "878114d6b1bda091bd3b3d8aa819831d84381682feba78267592d2593295af90"},
{"source": "app.modules.rtorrent.rtorrent", "target": "xmlrpc.client.ServerProxy", "kind": "protocol_operation", "fingerprint": "2ac64b5670c930bc28bb2135cc4b73891c1cae01a74a9586f74e302878c45c85"},

View File

@@ -9985,6 +9985,23 @@
"target": "app.runtime.event.snapshot.snapshot_event_data"
}
],
"app.sdk.feishu": [
{
"kind": "import",
"name": "FEISHU_DOMAIN",
"target": "app.adapters.network.feishu.FEISHU_DOMAIN"
},
{
"kind": "import",
"name": "FatalConnectionError",
"target": "app.adapters.network.feishu.FatalConnectionError"
},
{
"kind": "import",
"name": "FeishuLongConnection",
"target": "app.adapters.network.feishu.FeishuLongConnection"
}
],
"app.sdk.logging": [
{
"kind": "import",

View File

@@ -49,7 +49,7 @@ FROZEN_EGRESS_EDGES_BY_REASON = {
},
"streaming_protocol": {
("app.startup.composition.network", "websocket"),
("app.modules.feishu.longconn", "websocket"),
("app.adapters.network.feishu", "websocket"),
("app.modules.qqbot.gateway", "websocket"),
("app.modules.rtorrent.rtorrent", "socket.create_connection"),
("app.modules.rtorrent.rtorrent", "xmlrpc.client.ServerProxy"),
@@ -113,7 +113,7 @@ FROZEN_EGRESS_FINGERPRINT_BY_EDGE = {
("app.doctor.checks", "urllib.request"): "d8dd2279263ef58c3b4c59c70451533a4fc27d2ccd76960ca3aff29cd73f3354",
("app.modules.discord.discord", "discord"): "e1486525fedbfe574aa47d60b483426c8c48e4e27fa35dbe34f6cd2f47d37c86",
("app.modules.emby.emby", "requests"): "6c4c17fe170226ea1272f5859bc119c2775442cabe8ce2a9edcabaf9c37bec31",
("app.modules.feishu.longconn", "websocket"): "8ccfc3b86ad029942cdf0cab9d01fa2e1498576ee59e0df7239e1a744b370eae",
("app.adapters.network.feishu", "websocket"): "95aca65eed288e0c7654ab49aad6c535d9313d5e38c44b1cb1768dcdfc772453",
("app.modules.filemanager.storages.smb", "smbclient"): "37619f050b03598fa559c6ae2df94bda6adf7cffd1cc42d9db9059f1fb41af47",
("app.modules.filemanager.storages.smb", "smbprotocol"): "f02686afd99c59820dffa7b4c2627a0be1ad62980c98a9cd33b7564697842b22",
("app.modules.filemanager.storages.u115", "oss2"): "f7b89c8ae6dad2603f0a9e0caaa159769aef5b3581d7e728f62445b979366eae",

View File

@@ -10,8 +10,8 @@ import pytest
import websocket
from websockets.sync.server import serve
from app.modules.feishu import longconn
from app.modules.feishu.longconn import FatalConnectionError, FeishuLongConnection, Frame
from app.adapters.network import feishu as longconn
from app.adapters.network.feishu import FatalConnectionError, FeishuLongConnection, Frame
# 以下字节由 lark-oapi 1.6 的 pbbp2_pb2.Frame.SerializeToString() 生成,作为协议对照样本。
SDK_PING_FRAME = bytes.fromhex("08001000180720002a0c0a0474797065120470696e67")

View File

@@ -11,8 +11,8 @@ ensure_optional_stub("psutil")
ensure_optional_stub("dateparser")
ensure_optional_stub("Pinyin2Hanzi", is_pinyin=lambda value: False)
from app.adapters.network.feishu import FeishuLongConnection # noqa: E402
from app.modules.feishu.feishu import Feishu # noqa: E402
from app.modules.feishu.longconn import FeishuLongConnection # noqa: E402
from app.modules.feishu.openapi import FeishuOpenApi # noqa: E402
# 停止单个配置实例的耗时上限;远小于 Feishu._ws_join_timeout_seconds,证明停止不是靠等待超时。

View File

@@ -160,6 +160,16 @@ for name in (
assert result.returncode == 0, result.stderr
def test_feishu_sdk_exports_host_long_connection():
"""飞书 SDK 复用宿主传输实现,插件与宿主飞书模块是同一个类。"""
from app.adapters.network import feishu as feishu_adapter
from app.sdk import feishu as feishu_sdk
assert feishu_sdk.FeishuLongConnection is feishu_adapter.FeishuLongConnection
assert feishu_sdk.FatalConnectionError is feishu_adapter.FatalConnectionError
assert feishu_sdk.FEISHU_DOMAIN == "https://open.feishu.cn"
def test_browser_sdk_delegates_sync_and_async_launch(monkeypatch):
"""SDK 只转发浏览器参数,不复制宿主生命周期实现。"""
from app.sdk import browser as browser_sdk