fix: route Lark stream bot through international domain (#2148)

This commit is contained in:
zhulinsen
2026-08-03 22:36:53 +08:00
committed by GitHub
parent 2e6b361a1a
commit 01ee2fa86e
5 changed files with 161 additions and 5 deletions

View File

@@ -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
)

View File

@@ -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` 在关闭认证时如有缺失当前密码将阻止提交并给出内联提示。
<!-- 新条目格式:- [类型] 描述(类型取值:新功能/改进/修复/文档/测试/chore-->
<!-- 每条独立一行追加到本段末尾,无需分类标题,合并时冲突最小 -->
- [文档] FAQ 补充 macOS 桌面应用被 Gatekeeper quarantine 阻止启动时的受信任安装包临时放行步骤refs #2113)。

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)