diff --git a/bot/platforms/feishu_stream.py b/bot/platforms/feishu_stream.py index 5e4c5b13e..fb3f30976 100644 --- a/bot/platforms/feishu_stream.py +++ b/bot/platforms/feishu_stream.py @@ -32,6 +32,9 @@ import time logger = logging.getLogger(__name__) +FEISHU_DOMAIN = "https://open.feishu.cn" +LARK_DOMAIN = "https://open.larksuite.com" + # 尝试导入飞书 SDK try: import lark_oapi as lark @@ -43,7 +46,13 @@ try: CreateMessageRequest, CreateMessageRequestBody, ) + from lark_oapi.core.const import ( + FEISHU_DOMAIN as SDK_FEISHU_DOMAIN, + LARK_DOMAIN as SDK_LARK_DOMAIN, + ) + FEISHU_DOMAIN = SDK_FEISHU_DOMAIN + LARK_DOMAIN = SDK_LARK_DOMAIN FEISHU_SDK_AVAILABLE = True except ImportError: FEISHU_SDK_AVAILABLE = False @@ -55,6 +64,21 @@ from src.formatters import format_feishu_markdown, chunk_content_by_max_bytes from src.config import get_config +def _resolve_feishu_domain(domain: Optional[str]) -> str: + """Resolve the configured Feishu/Lark region to an SDK API domain.""" + raw_domain = str(domain or "feishu").strip().lower() + if raw_domain in ("feishu", FEISHU_DOMAIN.lower()): + return FEISHU_DOMAIN + if raw_domain in ("lark", LARK_DOMAIN.lower()): + return LARK_DOMAIN + + logger.warning( + "[Feishu Stream] 无效的 FEISHU_DOMAIN=%s,回退为 feishu", + raw_domain, + ) + return FEISHU_DOMAIN + + class FeishuReplyClient: """ 飞书消息回复客户端 @@ -62,23 +86,37 @@ class FeishuReplyClient: 使用飞书 API 发送回复消息。 """ - def __init__(self, app_id: str, app_secret: str): + def __init__( + self, + app_id: str, + app_secret: str, + domain: Optional[str] = None + ): """ Args: app_id: 飞书应用 ID app_secret: 飞书应用密钥 + domain: 飞书 API 区域(feishu/lark)或 SDK API 域名 """ if not FEISHU_SDK_AVAILABLE: raise ImportError("lark-oapi SDK 未安装") + config = get_config() + configured_domain = ( + domain + if domain is not None + else getattr(config, 'feishu_domain', 'feishu') + ) + self._domain = _resolve_feishu_domain(configured_domain) + self._client = lark.Client.builder() \ .app_id(app_id) \ .app_secret(app_secret) \ + .domain(self._domain) \ .log_level(lark.LogLevel.WARNING) \ .build() # 获取配置的最大字节数 - config = get_config() self._max_bytes = getattr(config, 'feishu_max_bytes', 20000) def _send_interactive_card(self, content: str, message_id: Optional[str] = None, @@ -538,12 +576,14 @@ class FeishuStreamClient: def __init__( self, app_id: Optional[str] = None, - app_secret: Optional[str] = None + app_secret: Optional[str] = None, + domain: Optional[str] = None ): """ Args: app_id: 应用 ID(不传则从配置读取) app_secret: 应用密钥(不传则从配置读取) + domain: 飞书 API 区域(feishu/lark)或 SDK API 域名 """ if not FEISHU_SDK_AVAILABLE: raise ImportError( @@ -556,6 +596,12 @@ class FeishuStreamClient: self._app_id = app_id or getattr(config, 'feishu_app_id', None) self._app_secret = app_secret or getattr(config, 'feishu_app_secret', None) + configured_domain = ( + domain + if domain is not None + else getattr(config, 'feishu_domain', 'feishu') + ) + self._domain = _resolve_feishu_domain(configured_domain) if not self._app_id or not self._app_secret: raise ValueError( @@ -581,7 +627,11 @@ class FeishuStreamClient: def _create_event_handler(self) -> 'lark.EventDispatcherHandler': """创建事件分发处理器""" # 创建回复客户端 - self._reply_client = FeishuReplyClient(self._app_id, self._app_secret) + self._reply_client = FeishuReplyClient( + self._app_id, + self._app_secret, + domain=self._domain, + ) # 创建消息处理器 handler = FeishuStreamHandler( @@ -625,6 +675,7 @@ class FeishuStreamClient: app_id=self._app_id, app_secret=self._app_secret, event_handler=event_handler, + domain=self._domain, log_level=lark.LogLevel.WARNING, auto_reconnect=True ) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 983465bed..52604744a 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -9,6 +9,30 @@ and this project adheres to [Semantic Versioning](https://semver.org/). ## [Unreleased] +- [修复] Web 分享图改为用户点击“分享”后才按需生成,不再在报告加载时自动请求 +- [修复] 将 `SCREENING_ENABLED` 及 Web 选股功能开关归入“基础设置”,选股导航入口继续由该开关控制 +- [修复] 飞书交互机器人在 `FEISHU_DOMAIN=lark` 时让 Stream 长连接与消息回复统一使用 Lark 国际版 API 域名,避免 SDK 默认连接飞书国内域名并返回 `Incorrect domain name`(fixes #937)。 +- [修复] `scripts/ci_gate.sh` 的 `offline_test_suite` 给 `pytest -m "not network"` 加 `--timeout=120 -o timeout_method=thread` 与 `-o faulthandler_timeout=300`:单个测试(含其 teardown)超过 2 分钟直接 fail,单个测试(含其 teardown)超过 5 分钟时 dump 全部线程栈到 stderr。配合 `.github/requirements-ci.txt` 新增 `pytest-timeout>=2.3.0` 依赖。issue #2131 报告过 backend-gate 在 AlphaSift hotspot 用例附近间歇性无 traceback 卡住直到被 GitHub Actions 取消,此次修复让任何未来 CI hang 都会留下可定位的失败信息或 post-mortem 栈,而不是静默消亡。同步修正 `.github/workflows/docker-publish.yml` 的 `Install backend gate dependencies` 与 `setup-python cache-dependency-path` 对齐 `ci.yml` 的 backend-gate 依赖安装方式,避免发布流程跑同一个 `./scripts/ci_gate.sh` 时因缺少 `pytest-timeout` 而直接 fail。 + +- [修复] 选股策略栏稳定展示完整中文策略列表,并保留自定义策略 ID 入口 +- [修复] 选股热点详情统一使用中文业务文案,不再显示内部类名、字段名和原始数据源错误 +- [改进] 选中热点后先展示榜单已有摘要和核心股,后台再补充完整详情,并将单次热点源等待上限收紧为 8 秒 +- [修复] 刷新热点榜单并保留当前题材时同步绕过详情缓存重拉该题材,避免新榜单继续搭配旧路线与成分股;详情质量可用且字段完整时不再展示底层数据源尝试失败 +- [改进] 热点成分股并行获取东方财富与同花顺数据,并按固定数据源优先级合并;AkShare 调用复用 DSA 的可终止子进程 timeout,同花顺 HTTP 调用设置 connect/read timeout,并以进程级并发槽限制活跃任务、在返回前回收 worker;题材详情可按需复用 DSA 原生搜索服务的数据源优先级、时效过滤、缓存与同请求合并补充安全且带链接的近期消息,真实供应商调用使用限流且可终止回收的子进程,并在本地压缩摘要以避免额外 LLM 等待与降级提示;显式搜索增强不写入热点详情共享缓存或 Web 页面缓存 +- [修复] 选股尾部轮换以分析器输入顺序为权威并保留并列分候选顺序;热点消息增强分别追加 `route` 与原始 `timeline`,同键搜索只允许缓存 owner 启动供应商链,子进程启动或清理失败也会释放全局容量;热点默认 provider 的正数超时配置覆盖板块、成分股及直接详情 fallback 并传递剩余硬截止,关闭外层预算仍保留单源安全上限;主动新闻搜索以同一绝对 deadline 覆盖缓存等待、重新竞争和 provider 执行,并区分有效空结果与运行失败 +- [改进] 精简选股页面的重复说明,将任务标识、快照统计和排序诊断折叠到运行详情 +- [新功能] SkillAggregator 基于独立满足 30 条 evaluated 门槛的真实 Skill Outcome bucket,使用 Beta 先验收缩、unable 惩罚和多周期证据加权生成有界运行时权重;缺失、低样本或异常统计保持中性。 +- [改进] 将参考 AlphaSift 实现的选股核心与策略正式纳入 DSA,统一使用 `ScreeningService`、`SCREENING_ENABLED` 和 `/api/v1/screening`,并保留 Apache-2.0 归因与来源版本记录。 +- [新功能] 选股结果按 `run_id` 持久化到 DSA 数据库,新增运行历史和数据源历史 API,接入公告事件上下文及其搜索缓存,并支持将候选连同筛选策略映射的 skill 交给单股深度分析。 +- [修复] Outcome 候选按上次尝试时间公平调度,避免持续新增的缺失 key 使旧 `pending` outcome 永久得不到重试。 +- [新功能] 新增按 skill、horizon 与 outcome engine version 独立聚合的只读 Skill Opinion 表现统计;少于 30 条 evaluated 样本时仅返回观察性计数,不输出表现指标或调整运行时权重。 +- [修复] 选股主模型返回空内容、非 JSON 或低覆盖结构时继续尝试备用模型;全部失败时明确展示确定性因子排序状态。最终 JSON 必须在 `content` 或 `output` 块中;`reasoning_content`(链式思考)被视为内部辅助,不作为最终结果。 +- [改进] Web 选股使用浏览器匿名种子与运行 ID 在最终评分后的有界近分池中生成每次运行的候选组合;Web Storage 不可用时在页面会话内存中复用同一临时种子;本地评分覆盖完整短名单,远程分析继续遵守数量上限,且只有完成相同后置分析的候选可参与轮换;原 Top-N 前半部分、明显领先候选、硬过滤、风险否决和得分保持不变。 +- [改进] 热点榜单刷新与选股长流程解除双向串行等待,热点详情改为选中后按需加载;选股默认复用 5 分钟内且数据源优先级一致的成功全市场快照,同一来源链中的后备源结果也可复用,并在后台任务中展示快照、候选上下文、LLM 重排、最终评分和新闻事件增强阶段。 +- [修复] 选股日线增强改用请求级 DSA-first fetcher 注入,不再临时替换进程级函数,避免重叠请求泄漏 wrapper 或重复执行 fallback;多个后置分析器按最新分数逐级重排,远程分析状态跟随实际提交候选,超限候选统一记录为 `skipped`,外部响应也不能改写未提交候选。 +- [修复] 统一等价股票代码的本地日线候选与同源窗口解析;冲突沪深交易所代码不再降级匹配裸码,回测仅接受快照或交易日历确认的起点,并在同一起点中优先完整的单一代码窗口。 +- [新功能] 新增按 individual SkillAgent 自身 signal、版本化 engine 与本地已存同源日线窗口计算并持久化 `skill_opinion_outcomes` 的核心服务。 +- [修复] #1970 关闭认证属于高风险操作,即使携带有效 session cookie 也强制要求再次输入当前管理员密码二次确认;后端 `auth_update_settings` 的 disable 分支统一走 currentPassword 校验,命中 rate limit 时与 enable 路径一致返回 429,前端 `AuthSettingsCard` 在关闭认证时如有缺失当前密码将阻止提交并给出内联提示。 - [文档] FAQ 补充 macOS 桌面应用被 Gatekeeper quarantine 阻止启动时的受信任安装包临时放行步骤(refs #2113)。 diff --git a/docs/bot-command_EN.md b/docs/bot-command_EN.md index bc7ed84f6..ddcbb53fd 100644 --- a/docs/bot-command_EN.md +++ b/docs/bot-command_EN.md @@ -229,6 +229,7 @@ BOT_COMMAND_PREFIX=/ # --- Feishu (Lark) bot --- FEISHU_APP_ID= FEISHU_APP_SECRET= +FEISHU_DOMAIN=feishu # feishu (China) or lark (international/Lark) FEISHU_VERIFICATION_TOKEN= # Event verification token FEISHU_ENCRYPT_KEY= # Encryption key (optional) diff --git a/docs/bot/feishu-bot-config.md b/docs/bot/feishu-bot-config.md index a5cbe885c..1b28cd0de 100644 --- a/docs/bot/feishu-bot-config.md +++ b/docs/bot/feishu-bot-config.md @@ -44,6 +44,8 @@ FEISHU_CHAT_ID=oc_xxx FEISHU_RECEIVE_ID_TYPE=chat_id # 事件订阅 / Stream Bot 时才开启 FEISHU_STREAM_ENABLED=true +# Lark 国际版必须设置为 lark;飞书国内版保持 feishu +FEISHU_DOMAIN=feishu ``` 注意: @@ -51,6 +53,7 @@ FEISHU_STREAM_ENABLED=true - 简单群通知优先配置 `FEISHU_WEBHOOK_URL` - 不用 Webhook 时,App Bot 主动推送必须同时配置 `FEISHU_APP_ID`、`FEISHU_APP_SECRET` 和 `FEISHU_CHAT_ID` - `FEISHU_STREAM_ENABLED` 只代表事件订阅 / Stream Bot,不参与主动通知是否配置完成的判断 +- `FEISHU_DOMAIN=lark` 会让 App Bot 主动发送、Stream 长连接和消息回复统一使用 `open.larksuite.com`;飞书国内版使用默认值 `feishu` - 如果你做的是应用机器人 / Stream Bot,可直接看文末保留的原流程截图参考 - App Bot 发送路径复用 `requirements.txt` 中已有的 `lark-oapi>=1.0.0`,标准安装使用 `pip install -r requirements.txt`;参考 [Feishu message create OpenAPI](https://open.feishu.cn/document/server-docs/im-v1/message/create)、[lark-oapi PyPI](https://pypi.org/project/lark-oapi/) 和 [SDK repo](https://github.com/larksuite/oapi-sdk-python) diff --git a/tests/test_feishu_stream.py b/tests/test_feishu_stream.py index 404f0e103..8afe085f3 100644 --- a/tests/test_feishu_stream.py +++ b/tests/test_feishu_stream.py @@ -1,4 +1,12 @@ -from bot.platforms.feishu_stream import FeishuReplyClient +from unittest import mock + +import bot.platforms.feishu_stream as feishu_stream +from bot.platforms.feishu_stream import ( + FEISHU_DOMAIN, + LARK_DOMAIN, + FeishuReplyClient, + FeishuStreamClient, +) from src.formatters import format_feishu_markdown @@ -30,6 +38,75 @@ class DummyFeishuReplyClient(FeishuReplyClient): return True +def test_reply_client_uses_lark_domain_from_config(): + builder = mock.MagicMock() + builder.app_id.return_value = builder + builder.app_secret.return_value = builder + builder.domain.return_value = builder + builder.log_level.return_value = builder + + config = mock.Mock(feishu_domain="lark", feishu_max_bytes=20000) + with mock.patch.object(feishu_stream, "FEISHU_SDK_AVAILABLE", True), \ + mock.patch.object(feishu_stream, "get_config", return_value=config), \ + mock.patch.object(feishu_stream, "lark", create=True) as lark: + lark.Client.builder.return_value = builder + FeishuReplyClient("cli_test", "secret") + + builder.domain.assert_called_once_with(LARK_DOMAIN) + + +def test_stream_and_reply_clients_share_lark_domain(): + config = mock.Mock( + feishu_app_id="cli_test", + feishu_app_secret="secret", + feishu_domain="lark", + ) + reply_client = mock.Mock() + ws_client = mock.Mock() + + with mock.patch.object(feishu_stream, "FEISHU_SDK_AVAILABLE", True), \ + mock.patch("src.config.get_config", return_value=config), \ + mock.patch.object( + feishu_stream, + "FeishuReplyClient", + return_value=reply_client, + ) as reply_client_class, \ + mock.patch.object(feishu_stream, "ws", create=True) as ws, \ + mock.patch.object(feishu_stream, "lark", create=True) as lark: + ws.Client.return_value = ws_client + client = FeishuStreamClient() + with mock.patch.object(client, "_create_event_handler", return_value=mock.Mock()): + client.start() + + ws.Client.assert_called_once_with( + app_id="cli_test", + app_secret="secret", + event_handler=mock.ANY, + domain=LARK_DOMAIN, + log_level=lark.LogLevel.WARNING, + auto_reconnect=True, + ) + reply_client_class.assert_not_called() + ws_client.start.assert_called_once_with() + + with mock.patch.object(feishu_stream, "FeishuReplyClient") as reply_client_class: + client._create_message_handler = mock.Mock(return_value=mock.Mock()) + with mock.patch.object(feishu_stream, "FeishuStreamHandler"), \ + mock.patch.object(feishu_stream, "lark", create=True): + client._create_event_handler() + + reply_client_class.assert_called_once_with( + "cli_test", + "secret", + domain=LARK_DOMAIN, + ) + + +def test_invalid_stream_domain_falls_back_to_feishu(caplog): + assert feishu_stream._resolve_feishu_domain("invalid") == FEISHU_DOMAIN + assert "回退为 feishu" in caplog.text + + def test_reply_text_chunked_keeps_reply_and_at_user(monkeypatch): client = DummyFeishuReplyClient(max_bytes=1000)