diff --git a/README.md b/README.md index 9d4873ec0..f4e0ad687 100644 --- a/README.md +++ b/README.md @@ -135,7 +135,7 @@ Fully modular pipeline from document parsing, vectorization, and retrieval to LL | Embeddings | Ollama / BGE / GTE / Zhipu / OpenAI-compatible APIs | | Vector DBs | PostgreSQL (pgvector) / Elasticsearch / OpenSearch / Milvus / Weaviate / Qdrant / Apache Doris / Tencent VectorDB | | Object Storage | Local / MinIO / AWS S3 / Volcengine TOS / Alibaba Cloud OSS / Kingsoft Cloud KS3 / Huawei Cloud OBS | -| IM Channels | WeCom / Feishu / Slack / Telegram / DingTalk / Mattermost / WeChat | +| IM Channels | WeCom / Feishu / Lark / Slack / Telegram / DingTalk / Mattermost / WeChat | | Website Embed | Publish agents via embed widget with domain allowlists, rate limits, and secure-mode token exchange | | Web Search | DuckDuckGo / Bing / Google / Tavily / Baidu / Ollama / SearXNG | diff --git a/README_CN.md b/README_CN.md index c49fc2b3e..a961b3557 100644 --- a/README_CN.md +++ b/README_CN.md @@ -134,7 +134,7 @@ | 向量数据库 | PostgreSQL (pgvector) / Elasticsearch / OpenSearch / Milvus / Weaviate / Qdrant / Apache Doris / 腾讯云 VectorDB | | Embedding | Ollama / BGE / GTE / 智谱 / OpenAI 兼容接口 | | 对象存储 | 本地 / 腾讯云COS / 火山引擎 TOS / MinIO / AWS S3 / 阿里云 OSS / 金山云 KS3 / 华为云 OBS | -| IM 集成 | 企业微信 / 飞书 / Slack / Telegram / 钉钉 / Mattermost / 微信 | +| IM 集成 | 企业微信 / 飞书 / Lark / Slack / Telegram / 钉钉 / Mattermost / 微信 | | 网站嵌入 | 通过嵌入 Widget 发布智能体,支持域名白名单、限流与安全模式 Token 交换 | | 网络搜索 | DuckDuckGo / Bing / Google / Tavily / Baidu / Ollama / SearXNG | diff --git a/README_JA.md b/README_JA.md index f8ccfcd26..2aa2f06e5 100644 --- a/README_JA.md +++ b/README_JA.md @@ -135,7 +135,7 @@ Feishu、Notion、Yuqueなどの外部プラットフォームからのナレッ | Embedding | Ollama / BGE / GTE / OpenAI 互換 API | | ベクトル DB | PostgreSQL (pgvector) / Elasticsearch / OpenSearch / Milvus / Weaviate / Qdrant / Apache Doris / Tencent VectorDB | | オブジェクトストレージ | ローカル / MinIO / AWS S3 / 火山引擎 TOS / Alibaba Cloud OSS / 金山雲 KS3 | -| IM 統合 | WeChat Work / Feishu / Slack / Telegram / DingTalk / Mattermost / WeChat | +| IM 統合 | WeChat Work / Feishu / Lark / Slack / Telegram / DingTalk / Mattermost / WeChat | | ウェブ埋め込み | 埋め込み Widget でエージェントを公開、ドメイン許可リスト・レート制限・セキュアモード Token 交換 | | Web 検索 | DuckDuckGo / Bing / Google / Tavily / Baidu / Ollama / SearXNG | diff --git a/README_KO.md b/README_KO.md index d6e6490be..1a5410f19 100644 --- a/README_KO.md +++ b/README_KO.md @@ -144,7 +144,7 @@ Feishu, Notion, Yuque 등 외부 플랫폼에서 지식 자동 동기화를 지 | Embedding | Ollama / BGE / GTE / OpenAI 호환 API | | 벡터 DB | PostgreSQL (pgvector) / Elasticsearch / OpenSearch / Milvus / Weaviate / Qdrant / Apache Doris / Tencent VectorDB | | 오브젝트 스토리지 | 로컬 / MinIO / AWS S3 / Volcengine TOS / Alibaba Cloud OSS / Kingsoft Cloud KS3 | -| IM 통합 | WeChat Work / Feishu / Slack / Telegram / DingTalk / Mattermost / WeChat | +| IM 통합 | WeChat Work / Feishu / Lark / Slack / Telegram / DingTalk / Mattermost / WeChat | | 웹사이트 임베드 | 임베드 Widget으로 에이전트 게시, 도메인 허용 목록·속도 제한·보안 모드 Token 교환 | | 웹 검색 | DuckDuckGo / Bing / Google / Tavily / Baidu / Ollama / SearXNG | diff --git a/docs/IM集成开发文档.md b/docs/IM集成开发文档.md index 117ae1c1f..7779c5266 100644 --- a/docs/IM集成开发文档.md +++ b/docs/IM集成开发文档.md @@ -1,6 +1,6 @@ # IM 集成开发文档 -WeKnora 的 IM 集成模块将企业即时通讯平台(企业微信、飞书、Slack、Telegram、钉钉、Mattermost)接入 WeKnora 知识问答管道,支持在 IM 中直接向 AI 提问并获得实时流式回答。 +WeKnora 的 IM 集成模块将企业即时通讯平台(企业微信、飞书、Lark、Slack、Telegram、钉钉、Mattermost)接入 WeKnora 知识问答管道,支持在 IM 中直接向 AI 提问并获得实时流式回答。 IM 渠道绑定到 Agent,一个 Agent 可接入多个 IM 渠道,所有配置通过前端 Agent 编辑器管理,存储在数据库中。 @@ -9,6 +9,7 @@ IM 渠道绑定到 Agent,一个 Agent 可接入多个 IM 渠道,所有配置 - [快速接入指南](#快速接入指南) - [企业微信接入](#企业微信接入) - [飞书接入](#飞书接入) + - [Lark 接入](#lark-接入) - [Slack 接入](#slack-接入) - [Telegram 接入](#telegram-接入) - [钉钉接入](#钉钉接入) @@ -22,7 +23,7 @@ IM 渠道绑定到 Agent,一个 Agent 可接入多个 IM 渠道,所有配置 - [接口定义](#接口定义) - [平台适配器详解](#平台适配器详解) - [企业微信 (WeCom)](#企业微信-wecom) - - [飞书 (Feishu)](#飞书-feishu) + - [飞书 (Feishu) 与 Lark](#飞书-feishu-与-lark) - [Slack](#slack) - [Telegram](#telegram) - [钉钉 (DingTalk)](#钉钉-dingtalk) @@ -234,6 +235,64 @@ IM 渠道绑定到 Agent,一个 Agent 可接入多个 IM 渠道,所有配置 --- +### Lark 接入 + +Lark 是飞书的国际版。两者是同一产品部署在两朵相互隔离的云上,**IM 的 API 接口、事件结构、 +凭证字段、接入模式一致**,因此 WeKnora 中 Lark 与飞书共用同一套适配器代码。 + +但「代码一致」不等于「配置一致」,接入时有三处差别: + +| | 飞书 | Lark | +|---|---|---| +| 开放平台 | | | +| 添加渠道时的**平台**选项 | 「飞书」 | 「Lark(飞书国际版)」 | +| 权限清单 | [飞书接入](#飞书接入)一节的 JSON | [Lark 权限配置](#lark-权限配置)一节的 JSON(**不可套用飞书的**) | + +创建应用、添加机器人能力、订阅 `im.message.receive_v1` 事件、发布版本、填写 +App ID / App Secret 等步骤与[飞书接入](#飞书接入)一节相同。**唯独权限配置不同,见下。** + +#### Lark 权限配置 + +在 Lark 开放平台 **权限管理 → 批量导入** 粘贴下面的 JSON,覆盖 IM 机器人与知识库(Wiki) +数据源两块功能: + +```json +{ + "scopes": { + "tenant": [ + "im:message", + "im:message:send_as_bot", + "im:resource", + "im:message.p2p_msg:readonly", + "im:message.group_at_msg:readonly", + "cardkit:card:write", + "wiki:wiki:readonly", + "drive:export:readonly", + "drive:drive:readonly", + "docx:document:readonly" + ], + "user": [] + } +} +``` + +> 不要套用[飞书接入](#飞书接入)一节的 JSON:其中 `aily:file:*`、`corehr:file:download` +> 在 Lark 不存在,整份导入会失败。 + +> **注意**:飞书应用与 Lark 应用互不通用。在 open.feishu.cn 创建的应用无法用于 Lark 渠道, +> 反之亦然——凭证只在创建它的那朵云上有效,跨云调用会认证失败。 + +WebSocket 模式启动后日志出现以下内容表示连接成功: + +``` +[IM] Lark WebSocket connecting (app_id=xxx)... +``` + +同一个 App ID 在两朵云上分别注册时,WeKnora 通过 `平台:app_id` 区分(如 `feishu:cli_xxx` +与 `lark:cli_xxx`),两个渠道可以并存,会话互不干扰。 + +--- + ### Slack 接入 Slack 提供两种接入模式,推荐使用 WebSocket (Socket Mode) 模式,无需公网域名。 @@ -520,7 +579,7 @@ IM 渠道在 Agent 编辑器的 **IM 集成** 标签页中管理(仅编辑模 ### 渠道列表 每个渠道以卡片形式展示,包含: -- **平台标识**:企业微信(绿色)/ 飞书(蓝色)/ Slack(紫色)/ Telegram(蓝色)/ 钉钉(蓝色)/ Mattermost(蓝色) +- **平台标识**:企业微信(绿色)/ 飞书(青色)/ Lark(蓝色)/ Slack(紫色)/ Telegram(蓝色)/ 钉钉(蓝色)/ Mattermost(蓝色) - **渠道名称**:用户自定义 - **接入模式**:WebSocket / Webhook - **输出模式**:流式输出 / 完整输出 @@ -615,7 +674,7 @@ CREATE TABLE im_channels ( id VARCHAR(36) PRIMARY KEY, tenant_id BIGINT NOT NULL, agent_id VARCHAR(36) NOT NULL, -- 绑定的 Agent ID - platform VARCHAR(20) NOT NULL, -- 'wecom' | 'feishu' | 'slack' | 'telegram' | 'dingtalk' | 'mattermost' + platform VARCHAR(20) NOT NULL, -- 'wecom' | 'feishu' | 'lark' | 'slack' | 'telegram' | 'dingtalk' | 'mattermost' name VARCHAR(255) NOT NULL DEFAULT '', enabled BOOLEAN NOT NULL DEFAULT true, mode VARCHAR(20) NOT NULL DEFAULT 'websocket', -- 'webhook' | 'websocket' @@ -635,8 +694,8 @@ CREATE TABLE im_channels ( |------|------|------| | 企业微信 | WebSocket | `bot_id`, `bot_secret` | | 企业微信 | Webhook | `corp_id`, `agent_secret`, `token`, `encoding_aes_key`, `corp_agent_id` | -| 飞书 | WebSocket | `app_id`, `app_secret` | -| 飞书 | Webhook | `app_id`, `app_secret`, `verification_token`, `encrypt_key` | +| 飞书 / Lark | WebSocket | `app_id`, `app_secret` | +| 飞书 / Lark | Webhook | `app_id`, `app_secret`, `verification_token`, `encrypt_key` | | Slack | WebSocket | `app_token`, `bot_token` | | Slack | Webhook | `bot_token`, `signing_secret` | | Telegram | WebSocket | `bot_token` | @@ -697,7 +756,7 @@ CREATE TABLE im_channels ( ```go type IncomingMessage struct { - Platform Platform // "wecom" | "feishu" | "slack" | "telegram" | "dingtalk" | "mattermost" + Platform Platform // "wecom" | "feishu" | "lark" | "slack" | "telegram" | "dingtalk" | "mattermost" MessageType MessageType // "text" | "file" | "image" UserID string // 平台用户标识 UserName string // 显示名 (可选) @@ -921,10 +980,71 @@ LongConnClient ══WebSocket══▶ wss://openws.work.weixin.qq.com --- -### 飞书 (Feishu) +### 飞书 (Feishu) 与 Lark 统一适配器同时支持 Webhook 和 WebSocket 模式,且原生实现 `StreamSender` 和 `FileDownloader` 接口。 +#### 双云共用一套适配器 + +飞书与 Lark 是同一产品部署在两朵隔离的云上,API 与事件结构完全一致,因此 `internal/im/feishu` +包同时服务两个平台,由 `Region` 决定具体连哪朵云: + +```go +// internal/im/feishu/region.go +type Region struct { + Platform im.Platform // "feishu" | "lark",写入 IncomingMessage + OpenBaseURL string // https://open.feishu.cn | https://open.larksuite.com + Label string // 日志前缀 [Feishu] / [Lark] + ThinkingText string // 流式卡片占位文案(中文 / 英文) + ImageFallbackLabel string // 图片上传失败时降级链接的文案 +} + +var ( + RegionFeishu = Region{Platform: im.PlatformFeishu, OpenBaseURL: "https://open.feishu.cn", ...} + RegionLark = Region{Platform: im.PlatformLark, OpenBaseURL: "https://open.larksuite.com", ...} +) +``` + +容器中注册为两个平台: + +```go +// internal/container/container.go +imService.RegisterAdapterFactory("feishu", feishu.NewFactory(feishu.RegionFeishu)) +imService.RegisterAdapterFactory("lark", feishu.NewFactory(feishu.RegionLark)) +``` + +适配器所有 API 调用经 `a.api(path, args...)` 拼接 `region.OpenBaseURL`;WebSocket 模式通过 +`larkws.WithDomain(region.OpenBaseURL)` 指向对应云(SDK 默认连 open.feishu.cn,Lark 不指定会认证失败)。 + +> **跨云资源不通用**:`tenant_access_token`、`image_key`、`file_key`、`card_id` 均由单朵云的 +> 单个应用签发,跨云或跨应用使用会被拒绝。图片上传缓存因此以 `app_id` 为前缀分区。 + +#### IM 适配器实际调用的接口与所需权限 + +下表说明适配器**实际**依赖哪些权限。表中的权限标识已对照 Lark 官方 API 文档逐条核对, +飞书侧同名。 + +> **两朵云的权限清单并不等价。** API 与事件结构一致,不代表权限目录一致——飞书开放平台 +> 上存在的部分权限,Lark 开放平台并没有。因此各自的配置清单请分别参照 +> [飞书接入](#飞书接入)与 [Lark 权限配置](#lark-权限配置),不要跨云复制粘贴。 +> +> 飞书一节的 JSON 还同时覆盖了**飞书数据源连接器**(Wiki 同步),所以包含 +> `wiki:wiki:readonly`、`docs:document.content:read`、`sheets:spreadsheet` 等 IM 用不到的权限。 + +| 适配器调用 | 用途 | 所需权限(满足其一即可) | +|---|---|---| +| `POST /auth/v3/tenant_access_token/internal` | 换取 tenant access token | 无需权限(凭 App ID / Secret) | +| `POST /im/v1/messages/{id}/reply` | 回复消息(主路径) | `im:message`、`im:message:send_as_bot` 或 `im:message:send` | +| `POST /im/v1/messages` | 发送消息(回复失败时降级) | 同上 | +| `GET /im/v1/messages/{id}/resources/{key}` | 下载用户发来的文件 / 图片 | `im:message` 或 `im:message:readonly` | +| `POST /im/v1/images` | 上传图片以取得 `image_key` | `im:resource` 或 `im:resource:upload` | +| `POST /cardkit/v1/cards``PUT /cardkit/v1/cards/{id}/elements/{eid}/content``PATCH /cardkit/v1/cards/{id}/settings` | 流式卡片(仅流式输出模式需要) | `cardkit:card:write` | +| 订阅事件 `im.message.receive_v1`(单聊) | 接收私聊消息 | `im:message.p2p_msg` 或 `im:message.p2p_msg:readonly` | +| 订阅事件 `im.message.receive_v1`(群聊 @) | 接收群内 @机器人 消息 | `im:message.group_at_msg` 或 `im:message.group_at_msg:readonly` | + +因此 IM 功能全开(私聊 + 群聊 @ + 文件 + 图片 + 流式卡片)最少需要 5 类权限:收私聊、收群 @、 +发消息、读写资源、写卡片。若关闭流式输出(输出模式选「完整输出」),`cardkit:card:write` 可省略。 + #### Webhook 模式 ``` @@ -974,14 +1094,21 @@ EndStream: > [thinking content line 2] ``` +> **已知限制**:`Region.ThinkingText` 只本地化了卡片的初始占位文案(Lark 显示 `Thinking...`)。 +> 一旦开始流式输出,think 块标题来自共享的 `MarkdownThinkStyle`,目前对所有平台 +> (Lark、Slack、Telegram、Mattermost 等)都是中文。本地化它需要把 locale 贯穿到 +> `FormatIMDisplayContent`,属于跨平台改动,不在 Lark 接入范围内。 + **孤立流清理:** 后台协程每 1 分钟扫描超过 5 分钟未关闭的流式卡片,自动调用 `EndStream` 关闭(防止内存泄漏)。 #### 源码文件 | 文件 | 职责 | |------|------| +| `internal/im/feishu/region.go` | `Region` 定义(飞书 / Lark 的域名、平台标识、日志前缀、本地化文案) | | `internal/im/feishu/adapter.go` | 事件解析、CardKit 流式实现、Token 缓存、AES 解密、Think 块转换、文件下载 | | `internal/im/feishu/longconn.go` | WebSocket 长连接(封装飞书 SDK)、事件分发 | +| `internal/im/feishu/factory.go` | 按 `Region` 构造适配器与长连接客户端 | --- diff --git a/docs/wiki/集成扩展/IM集成开发.md b/docs/wiki/集成扩展/IM集成开发.md index 3c93fe7b0..4510c34c0 100644 --- a/docs/wiki/集成扩展/IM集成开发.md +++ b/docs/wiki/集成扩展/IM集成开发.md @@ -1,13 +1,13 @@ --- title: IM集成开发 -tags: [集成扩展, IM, 企微, 飞书, Slack, Telegram, 钉钉, Mattermost] +tags: [集成扩展, IM, 企微, 飞书, Lark, Slack, Telegram, 钉钉, Mattermost] aliases: [IM集成, IM开发, 企业微信, 即时通讯] source: IM集成开发文档.md --- # IM 集成开发 -WeKnora 的 IM 集成模块将企业即时通讯平台(企业微信、飞书、Slack、Telegram、钉钉、Mattermost)接入 WeKnora 知识问答管道,支持在 IM 中直接向 AI 提问并获得实时流式回答。 +WeKnora 的 IM 集成模块将企业即时通讯平台(企业微信、飞书、Lark、Slack、Telegram、钉钉、Mattermost)接入 WeKnora 知识问答管道,支持在 IM 中直接向 AI 提问并获得实时流式回答。 IM 渠道绑定到 Agent,一个 Agent 可接入多个 IM 渠道。 @@ -19,6 +19,7 @@ IM 渠道绑定到 Agent,一个 Agent 可接入多个 IM 渠道。 |------|:-:|:-:|:-:| | 企业微信 | ✅ | ✅ | ✅ | | 飞书 | ✅ | ✅ | ✅ (CardKit) | +| Lark(飞书国际版) | ✅ | ✅ | ✅ (CardKit) | | Slack | ✅ (Socket Mode) | ✅ (Events API) | ✅ | | Telegram | ✅ (长轮询) | ✅ | ✅ | | 钉钉 | ✅ (Stream) | ✅ | ✅ (AI 卡片) | @@ -47,6 +48,20 @@ IM 渠道绑定到 Agent,一个 Agent 可接入多个 IM 渠道。 > 飞书同时也是数据源导入的支持平台,参见 [数据源导入开发](数据源导入开发.md) +### Lark 接入 + +Lark 是飞书的国际版,IM 接口一致,与飞书共用同一套适配器。接入步骤基本相同,但开放平台 +与**权限清单**不同: + +- 飞书: +- Lark: + +> **权限不可套用飞书的清单**:飞书清单混入了数据源连接器(Wiki 同步)用的权限,其中部分在 +> Lark 并不存在,整份导入会失败。Lark 只需 IM 相关的 6 项,见 +> [IM集成开发文档 — Lark 权限配置](../../IM集成开发文档.md#lark-权限配置)。 + +> 两朵云的应用互不通用,凭证只在创建它的那朵云上有效。 + ### Slack 接入 - **Socket Mode**(推荐)— 无需公网域名 diff --git a/docs/数据源导入开发文档.md b/docs/数据源导入开发文档.md index a5255c315..172aaaeb4 100644 --- a/docs/数据源导入开发文档.md +++ b/docs/数据源导入开发文档.md @@ -82,7 +82,42 @@ WeKnora 的数据源导入模块支持从外部平台(飞书、企业微信、 - **成功**:显示已创建/更新/跳过/失败的文档数量 - **失败**:显示错误信息,常见原因为权限不足或 Token 过期 -> **注意**:飞书国际版(Lark)同样支持,在创建数据源时会自动适配 `https://open.larksuite.com` 的 API 地址。 +--- + +### Lark 知识库接入 + +Lark 是飞书的国际版。Wiki / docx / drive 接口与飞书一致,**共用同一套连接器代码**,接入步骤 +与[飞书知识库接入](#飞书知识库接入)相同,差别只有三处: + +| | 飞书 | Lark | +|---|---|---| +| 开放平台 | | | +| 连接器类型 | 「飞书」 | 「Lark」 | +| API 地址 | `https://open.feishu.cn` | `https://open.larksuite.com`(选中 Lark 类型后自动使用) | + +#### Lark 权限 + +权限标识与飞书相同,在 Lark 开放平台的 **权限管理** 中开通: + +| 权限 | 说明 | Lark 官方文档核对 | +|------|------|------| +| `wiki:wiki:readonly` | 读取知识库空间列表和节点树 | ✅ 已核对 | +| `drive:export:readonly` | 导出文档内容(docx/xlsx) | ✅ 已核对 | +| `drive:drive:readonly` | 读取云文档基础信息、下载文件 | ✅ 已核对 | +| `docx:document:readonly` | 读取新版文档内容 | ⚠️ 未能核对(Lark 文档页渲染受限),沿用飞书侧标识 | + +> **不要套用 [IM 集成文档](./IM集成开发文档.md#飞书接入)里的那份权限 JSON**:那份清单混入了 +> `aily:file:*`、`corehr:file:download` 等在 Lark 并不存在的权限(整份导入会失败), +> 且反而**缺少**上表中数据源真正需要的 `drive:*` / `docx:*`。 +> +> 若同一个 Lark 应用既做 IM 机器人又做知识库同步,请使用 +> [IM 集成文档 — Lark 权限配置](./IM集成开发文档.md#lark-权限配置)中的合并清单。 + +#### 应用互不通用 + +在 open.feishu.cn 创建的应用无法读取 Lark 知识库,反之亦然——凭证只在创建它的那朵云上有效。 +若你此前用「飞书」连接器加 `base_url=https://open.larksuite.com` 的方式接入过 Lark,该配置 +仍然有效(`base_url` 保留为显式覆盖项),但新建数据源请直接选「Lark」类型。 --- diff --git a/frontend/src/api/agent/index.ts b/frontend/src/api/agent/index.ts index 3863ebf1e..d02a21991 100644 --- a/frontend/src/api/agent/index.ts +++ b/frontend/src/api/agent/index.ts @@ -295,7 +295,8 @@ export interface IMChannel { id: string; tenant_id?: number; agent_id: string; - platform: 'wecom' | 'feishu' | 'slack' | 'telegram' | 'dingtalk' | 'mattermost' | 'wechat' | 'qqbot'; + // 'lark' is Feishu's international edition; it shares Feishu's credentials and modes. + platform: 'wecom' | 'feishu' | 'lark' | 'slack' | 'telegram' | 'dingtalk' | 'mattermost' | 'wechat' | 'qqbot'; name: string; enabled: boolean; mode: 'webhook' | 'websocket' | 'longpoll'; diff --git a/frontend/src/assets/img/datasource-lark.svg b/frontend/src/assets/img/datasource-lark.svg new file mode 100644 index 000000000..b3e710cf0 --- /dev/null +++ b/frontend/src/assets/img/datasource-lark.svg @@ -0,0 +1 @@ + diff --git a/frontend/src/assets/img/im/lark.svg b/frontend/src/assets/img/im/lark.svg new file mode 100644 index 000000000..b3e710cf0 --- /dev/null +++ b/frontend/src/assets/img/im/lark.svg @@ -0,0 +1 @@ + diff --git a/frontend/src/components/IMChannelPanel.vue b/frontend/src/components/IMChannelPanel.vue index 9600220bc..f27a9751b 100644 --- a/frontend/src/components/IMChannelPanel.vue +++ b/frontend/src/components/IMChannelPanel.vue @@ -295,11 +295,11 @@ - - + + - - {{ $t('agentEditor.im.feishuConsole') }} + + {{ $t(openPlatformConsole.labelKey) }} {{ $t('agentEditor.im.consoleTip') }} @@ -538,6 +538,7 @@ import SettingDrawer from '@/components/settings/SettingDrawer.vue'; import IntegrationsAgentFilter from '@/components/IntegrationsAgentFilter.vue'; import wecomLogo from '@/assets/img/im/wecom.svg'; import feishuLogo from '@/assets/img/im/feishu.svg'; +import larkLogo from '@/assets/img/im/lark.svg'; import slackLogo from '@/assets/img/im/slack.svg'; import telegramLogo from '@/assets/img/im/telegram.svg'; import dingtalkLogo from '@/assets/img/im/dingtalk.svg'; @@ -550,6 +551,7 @@ type IMPlatform = IMChannel['platform']; const PLATFORM_LOGO: Record = { wecom: wecomLogo, feishu: feishuLogo, + lark: larkLogo, slack: slackLogo, telegram: telegramLogo, dingtalk: dingtalkLogo, @@ -600,6 +602,7 @@ const drawerConfirmText = computed(() => const platformOptions = computed(() => ([ { value: 'wecom' as IMPlatform, label: t('agentEditor.im.wecom'), logo: wecomLogo }, { value: 'feishu' as IMPlatform, label: t('agentEditor.im.feishu'), logo: feishuLogo }, + { value: 'lark' as IMPlatform, label: t('agentEditor.im.lark'), logo: larkLogo }, { value: 'slack' as IMPlatform, label: t('agentEditor.im.slack'), logo: slackLogo }, { value: 'telegram' as IMPlatform, label: t('agentEditor.im.telegram'), logo: telegramLogo }, { value: 'dingtalk' as IMPlatform, label: t('agentEditor.im.dingtalk'), logo: dingtalkLogo }, @@ -608,6 +611,14 @@ const platformOptions = computed(() => ([ { value: 'qqbot' as IMPlatform, label: t('agentEditor.im.qqbot'), logo: qqbotLogo }, ])); +// Feishu and Lark are the same product on separate clouds, so each has its own +// open platform console. Bots must be created on the one matching the channel. +const openPlatformConsole = computed(() => + formData.value.platform === 'lark' + ? { url: 'https://open.larksuite.com/', labelKey: 'agentEditor.im.larkConsole' } + : { url: 'https://open.feishu.cn/', labelKey: 'agentEditor.im.feishuConsole' }, +); + const drawerTitle = computed(() => { if (editingChannel.value) { return formData.value.name?.trim() || t('agentEditor.im.unnamed'); @@ -708,7 +719,7 @@ function resolvedChannelName(): string { } function platformSupportsThread(platform: string): boolean { - return ['slack', 'mattermost', 'feishu', 'telegram'].includes(platform); + return ['slack', 'mattermost', 'feishu', 'lark', 'telegram'].includes(platform); } watch( diff --git a/frontend/src/components/menu.vue b/frontend/src/components/menu.vue index 8b39834f4..3b2d24151 100644 --- a/frontend/src/components/menu.vue +++ b/frontend/src/components/menu.vue @@ -255,6 +255,7 @@ const chatResources = useChatResourcesStore(); // visually consistent with the channels admin view. import wecomLogo from '@/assets/img/im/wecom.svg'; import feishuLogo from '@/assets/img/im/feishu.svg'; +import larkLogo from '@/assets/img/im/lark.svg'; import slackLogo from '@/assets/img/im/slack.svg'; import telegramLogo from '@/assets/img/im/telegram.svg'; import dingtalkLogo from '@/assets/img/im/dingtalk.svg'; @@ -265,6 +266,7 @@ import qqbotLogo from '@/assets/img/im/qqbot.png'; const PLATFORM_LOGO: Record = { wecom: wecomLogo, feishu: feishuLogo, + lark: larkLogo, slack: slackLogo, telegram: telegramLogo, dingtalk: dingtalkLogo, diff --git a/frontend/src/i18n/locales/en-US.ts b/frontend/src/i18n/locales/en-US.ts index 6cc33a1e3..10d048c49 100755 --- a/frontend/src/i18n/locales/en-US.ts +++ b/frontend/src/i18n/locales/en-US.ts @@ -5840,8 +5840,9 @@ export default { }, im: { title: 'IM Integration', - description: 'Connect agent to instant messaging platforms like WeCom, Feishu, Slack, Telegram, DingTalk, Mattermost, WeChat and QQBot', + description: 'Connect agent to instant messaging platforms like WeCom, Feishu, Lark, Slack, Telegram, DingTalk, Mattermost, WeChat and QQBot', feishu: 'Feishu', + lark: 'Lark', slack: 'Slack', telegram: 'Telegram', dingtalk: 'DingTalk', @@ -5868,6 +5869,7 @@ export default { docLink: 'Integration Guide', wecomConsole: 'WeCom Admin Console', feishuConsole: 'Feishu Open Platform', + larkConsole: 'Lark Open Platform', slackConsole: 'Slack API Console', telegramConsole: 'Telegram BotFather', dingtalkConsole: 'DingTalk Open Platform', @@ -6162,12 +6164,14 @@ export default { }, connector: { feishu: 'Feishu', + lark: 'Lark', notion: 'Notion', yuque: 'Yuque', rss: 'RSS / Atom Feed', }, connectorDesc: { feishu: 'Sync documents, spreadsheets and files from Feishu Wiki', + lark: 'Sync documents, spreadsheets and files from Lark Wiki (Feishu international)', notion: 'Sync pages and databases from Notion', yuque: 'Sync documents from Yuque knowledge bases', rss: 'Sync articles from RSS / Atom feeds', diff --git a/frontend/src/i18n/locales/ko-KR.ts b/frontend/src/i18n/locales/ko-KR.ts index 9a0666a43..35652f460 100755 --- a/frontend/src/i18n/locales/ko-KR.ts +++ b/frontend/src/i18n/locales/ko-KR.ts @@ -5777,6 +5777,7 @@ export default { description: "에이전트를 Feishu, Slack, Telegram, DingTalk, Mattermost, QQBot 등 인스턴트 메시징 플랫폼에 연결", wecom: "WeCom", feishu: "Feishu", + lark: "Lark", slack: "Slack", telegram: "Telegram", dingtalk: "DingTalk", @@ -5802,6 +5803,7 @@ export default { docLink: "통합 가이드 보기", wecomConsole: "WeCom 관리 콘솔", feishuConsole: "Feishu 개발 플랫폼", + larkConsole: "Lark 개발 플랫폼", slackConsole: "Slack API 콘솔", telegramConsole: "Telegram BotFather", dingtalkConsole: "DingTalk 개발 플랫폼", @@ -6167,12 +6169,14 @@ export default { }, connector: { feishu: "페이슈 (Feishu)", + lark: "Lark (Feishu 글로벌)", notion: "Notion", yuque: "위큐 (Yuque)", rss: "RSS / Atom 피드", }, connectorDesc: { feishu: "페이슈 위키에서 문서, 스프레드시트, 파일 동기화", + lark: "Lark 위키에서 문서, 스프레드시트, 파일 동기화", notion: "Notion에서 페이지 및 데이터베이스 동기화", yuque: "위큐 지식베이스에서 문서 동기화", rss: "RSS / Atom 피드에서 글 동기화", diff --git a/frontend/src/i18n/locales/ru-RU.ts b/frontend/src/i18n/locales/ru-RU.ts index b0230098e..9146d178e 100755 --- a/frontend/src/i18n/locales/ru-RU.ts +++ b/frontend/src/i18n/locales/ru-RU.ts @@ -5347,6 +5347,7 @@ export default { description: 'Подключите агента к платформам мгновенных сообщений, таким как Feishu, Slack, Telegram, DingTalk, Mattermost и QQBot', wecom: 'WeCom', feishu: 'Feishu', + lark: 'Lark', slack: 'Slack', telegram: 'Telegram', dingtalk: 'DingTalk', @@ -5372,6 +5373,7 @@ export default { docLink: 'Руководство по интеграции', wecomConsole: 'Консоль WeCom', feishuConsole: 'Платформа Feishu', + larkConsole: 'Платформа Lark', slackConsole: 'Консоль Slack API', telegramConsole: 'Telegram BotFather', dingtalkConsole: 'Платформа DingTalk', @@ -5988,12 +5990,14 @@ export default { }, connector: { feishu: 'Feishu (Фэйшу)', + lark: 'Lark', notion: 'Notion', yuque: 'Yuque (Юйцюэ)', rss: 'RSS / Atom лента', }, connectorDesc: { feishu: 'Синхронизация документов, таблиц и файлов из Feishu Wiki', + lark: 'Синхронизация документов, таблиц и файлов из Lark Wiki', notion: 'Синхронизация страниц и баз данных из Notion', yuque: 'Синхронизация документов из баз знаний Yuque', rss: 'Синхронизация статей из лент RSS / Atom', diff --git a/frontend/src/i18n/locales/zh-CN.ts b/frontend/src/i18n/locales/zh-CN.ts index 93b5f5eac..1da18fb55 100755 --- a/frontend/src/i18n/locales/zh-CN.ts +++ b/frontend/src/i18n/locales/zh-CN.ts @@ -5798,6 +5798,7 @@ export default { description: "将智能体接入即时通讯平台,支持企业微信、飞书、Slack、Telegram、钉钉、Mattermost、微信和 QQBot", wecom: "企业微信", feishu: "飞书", + lark: "Lark(飞书国际版)", slack: "Slack", telegram: "Telegram", dingtalk: "钉钉", @@ -5823,6 +5824,7 @@ export default { docLink: "查看接入文档", wecomConsole: "企业微信管理后台", feishuConsole: "飞书开放平台", + larkConsole: "Lark 开放平台", slackConsole: "Slack API 控制台", telegramConsole: "Telegram BotFather", dingtalkConsole: "钉钉开放平台", @@ -6187,12 +6189,14 @@ export default { }, connector: { feishu: "飞书", + lark: "Lark(飞书国际版)", notion: "Notion", yuque: "语雀", rss: "RSS / Atom 订阅", }, connectorDesc: { feishu: "同步飞书知识库中的文档、表格、文件", + lark: "同步 Lark 知识库中的文档、表格、文件(飞书国际版)", notion: "同步 Notion 中的页面和数据库", yuque: "同步语雀知识库中的文档", rss: "同步 RSS / Atom 订阅源中的文章", diff --git a/frontend/src/views/knowledge/settings/DataSourceEditorDialog.vue b/frontend/src/views/knowledge/settings/DataSourceEditorDialog.vue index ceffb6547..be0cd7cf3 100644 --- a/frontend/src/views/knowledge/settings/DataSourceEditorDialog.vue +++ b/frontend/src/views/knowledge/settings/DataSourceEditorDialog.vue @@ -368,6 +368,26 @@ const connectorDefs = computed(() => [ { key: 'app_secret', labelKey: 'datasource.field.appSecret', placeholder: '', secret: true }, ], }, + { + // Lark is Feishu's international cloud. Same wiki/docx/drive APIs and the + // same scope identifiers, but a separate console, tenant and app — an app + // created on open.feishu.cn cannot read a Lark wiki. + type: 'lark', + available: true, + docUrl: 'https://open.larksuite.com/app', + permissionDocUrl: 'https://open.larksuite.com/document/server-docs/docs/wiki-v2/wiki-overview', + permissionPageUrl: 'https://open.larksuite.com/app', + requiredPermissions: [ + 'wiki:wiki:readonly', + 'drive:drive:readonly', + 'drive:export:readonly', + 'docx:document:readonly', + ], + fields: [ + { key: 'app_id', labelKey: 'datasource.field.appId', placeholder: 'cli_xxxx' }, + { key: 'app_secret', labelKey: 'datasource.field.appSecret', placeholder: '', secret: true }, + ], + }, { type: 'notion', available: true, diff --git a/frontend/src/views/knowledge/settings/DataSourceTypeIcon.vue b/frontend/src/views/knowledge/settings/DataSourceTypeIcon.vue index 0a18c175a..163c38ea1 100644 --- a/frontend/src/views/knowledge/settings/DataSourceTypeIcon.vue +++ b/frontend/src/views/knowledge/settings/DataSourceTypeIcon.vue @@ -17,6 +17,8 @@ function fallbackText(type: string) { switch (type) { case 'feishu': return 'F' + case 'lark': + return 'L' case 'notion': return 'N' case 'yuque': diff --git a/frontend/src/views/knowledge/settings/datasourceIcons.ts b/frontend/src/views/knowledge/settings/datasourceIcons.ts index e45b4e2c3..78e1c2541 100644 --- a/frontend/src/views/knowledge/settings/datasourceIcons.ts +++ b/frontend/src/views/knowledge/settings/datasourceIcons.ts @@ -1,10 +1,12 @@ import feishuIcon from '@/assets/img/datasource-feishu.ico' +import larkIcon from '@/assets/img/datasource-lark.svg' import notionIcon from '@/assets/img/datasource-notion.ico' import yuqueIcon from '@/assets/img/datasource-yuque.ico' import rssIcon from '@/assets/img/datasource-rss.svg' export const datasourceIconMap: Record = { feishu: feishuIcon, + lark: larkIcon, notion: notionIcon, yuque: yuqueIcon, rss: rssIcon, diff --git a/internal/container/container.go b/internal/container/container.go index 52e099209..fff8c17c6 100644 --- a/internal/container/container.go +++ b/internal/container/container.go @@ -1550,7 +1550,9 @@ func registerWebSearchProviders(registry *infra_web_search.Registry) { // in its own subpackage to keep this file focused on wiring. func registerIMAdapterFactories(imService *imPkg.Service) { imService.RegisterAdapterFactory("wecom", wecom.NewFactory()) - imService.RegisterAdapterFactory("feishu", feishu.NewFactory()) + imService.RegisterAdapterFactory("feishu", feishu.NewFactory(feishu.RegionFeishu)) + // Lark is Feishu's international cloud: same adapter, different host/tenant. + imService.RegisterAdapterFactory("lark", feishu.NewFactory(feishu.RegionLark)) imService.RegisterAdapterFactory("slack", slack.NewFactory()) imService.RegisterAdapterFactory("telegram", telegram.NewFactory()) imService.RegisterAdapterFactory("dingtalk", dingtalk.NewFactory()) @@ -1571,9 +1573,13 @@ func initConnectorRegistry() (*datasource.ConnectorRegistry, error) { registry := datasource.NewConnectorRegistry() var errs error - if err := registry.Register(feishuConnector.NewConnector()); err != nil { + if err := registry.Register(feishuConnector.NewConnector(feishuConnector.RegionFeishu)); err != nil { errs = errors.Join(errs, fmt.Errorf("register feishu connector: %w", err)) } + // Lark is Feishu's international cloud: same connector, different host/tenant. + if err := registry.Register(feishuConnector.NewConnector(feishuConnector.RegionLark)); err != nil { + errs = errors.Join(errs, fmt.Errorf("register lark connector: %w", err)) + } if err := registry.Register(notionConnector.NewConnector()); err != nil { errs = errors.Join(errs, fmt.Errorf("register notion connector: %w", err)) } diff --git a/internal/datasource/connector.go b/internal/datasource/connector.go index 74dd7aaa5..53991099a 100644 --- a/internal/datasource/connector.go +++ b/internal/datasource/connector.go @@ -115,6 +115,14 @@ var ConnectorMetadataRegistry = map[string]ConnectorMetadata{ AuthType: "oauth2", Capabilities: []string{"incremental", "deletion_sync"}, }, + types.ConnectorTypeLark: { + Type: types.ConnectorTypeLark, + Name: "Lark", + Description: "Sync documents, wikis, and content from Lark (Feishu international)", + Priority: 0, + AuthType: "oauth2", + Capabilities: []string{"incremental", "deletion_sync"}, + }, types.ConnectorTypeNotion: { Type: types.ConnectorTypeNotion, Name: "Notion", diff --git a/internal/datasource/connector/feishu/connector.go b/internal/datasource/connector/feishu/connector.go index d810f3d20..3529fb311 100644 --- a/internal/datasource/connector/feishu/connector.go +++ b/internal/datasource/connector/feishu/connector.go @@ -15,24 +15,28 @@ import ( "github.com/Tencent/WeKnora/internal/types" ) -// Connector implements the datasource.Connector interface for Feishu. -type Connector struct{} +// Connector implements the datasource.Connector interface for Feishu and, with +// the same code, for Lark: the two clouds expose an identical wiki/docx/drive +// API surface. A Region picks the cloud — see region.go. +type Connector struct { + region Region +} -// NewConnector creates a new Feishu connector. -func NewConnector() *Connector { - return &Connector{} +// NewConnector creates a connector for the given region (RegionFeishu or RegionLark). +func NewConnector(region Region) *Connector { + return &Connector{region: region} } // Type returns the connector type identifier. func (c *Connector) Type() string { - return types.ConnectorTypeFeishu + return c.region.ConnectorType } const feishuWikiNodeResourceSeparator = ":" // Validate verifies that the Feishu configuration is valid by testing connectivity. func (c *Connector) Validate(ctx context.Context, config *types.DataSourceConfig) error { - feishuConfig, err := parseFeishuConfig(config) + feishuConfig, err := parseFeishuConfig(config, c.region) if err != nil { return err } @@ -57,7 +61,7 @@ func (c *Connector) Validate(ctx context.Context, config *types.DataSourceConfig func (c *Connector) ListResources( ctx context.Context, config *types.DataSourceConfig, parentID string, ) ([]types.Resource, error) { - feishuConfig, err := parseFeishuConfig(config) + feishuConfig, err := parseFeishuConfig(config, c.region) if err != nil { return nil, err } @@ -77,7 +81,7 @@ func (c *Connector) ListResources( Name: space.Name, Type: "wiki_space", Description: space.Description, - URL: fmt.Sprintf("https://feishu.cn/wiki/%s", space.SpaceID), + URL: c.region.wikiURL(space.SpaceID), HasChildren: true, Metadata: map[string]interface{}{ "visibility": space.Visibility, @@ -97,7 +101,7 @@ func (c *Connector) ListResources( resources := make([]types.Resource, 0, len(nodes)) for _, node := range nodes { - resources = append(resources, wikiNodeToResource(spaceID, node)) + resources = append(resources, c.wikiNodeToResource(spaceID, node)) } return resources, nil } @@ -110,7 +114,7 @@ func (c *Connector) ListResources( func (c *Connector) ResolveResourceAncestors( ctx context.Context, config *types.DataSourceConfig, resourceIDs []string, ) ([]string, error) { - feishuConfig, err := parseFeishuConfig(config) + feishuConfig, err := parseFeishuConfig(config, c.region) if err != nil { return nil, err } @@ -159,7 +163,7 @@ func (c *Connector) ResolveResourceAncestors( // FetchAll performs a full sync of all documents from the specified wiki spaces. func (c *Connector) FetchAll(ctx context.Context, config *types.DataSourceConfig, resourceIDs []string) ([]types.FetchedItem, error) { - feishuConfig, err := parseFeishuConfig(config) + feishuConfig, err := parseFeishuConfig(config, c.region) if err != nil { return nil, err } @@ -207,7 +211,7 @@ func (c *Connector) FetchAll(ctx context.Context, config *types.DataSourceConfig // FetchIncremental performs an incremental sync by comparing node edit times // against the previously recorded state. func (c *Connector) FetchIncremental(ctx context.Context, config *types.DataSourceConfig, cursor *types.SyncCursor) ([]types.FetchedItem, *types.SyncCursor, error) { - feishuConfig, err := parseFeishuConfig(config) + feishuConfig, err := parseFeishuConfig(config, c.region) if err != nil { return nil, nil, err } @@ -400,7 +404,7 @@ func (c *Connector) fetchNodeContent(ctx context.Context, client *Client, node w Content: data, ContentType: "application/octet-stream", FileName: fileName, - URL: fmt.Sprintf("https://feishu.cn/wiki/%s", node.NodeToken), + URL: c.region.wikiURL(node.NodeToken), UpdatedAt: editTime, SourceResourceID: resourceID, Metadata: baseMeta, @@ -425,7 +429,7 @@ func (c *Connector) fetchNodeContent(ctx context.Context, client *Client, node w Content: data, ContentType: "application/octet-stream", FileName: fileName, - URL: fmt.Sprintf("https://feishu.cn/wiki/%s", node.NodeToken), + URL: c.region.wikiURL(node.NodeToken), UpdatedAt: editTime, SourceResourceID: resourceID, Metadata: baseMeta, @@ -447,7 +451,7 @@ func parseWikiResourceID(resourceID string) (spaceID string, nodeToken string) { return spaceID, nodeToken } -func wikiNodeToResource(spaceID string, node wikiNode) types.Resource { +func (c *Connector) wikiNodeToResource(spaceID string, node wikiNode) types.Resource { parentID := spaceID if node.ParentNodeID != "" { parentID = makeWikiNodeResourceID(spaceID, node.ParentNodeID) @@ -467,7 +471,7 @@ func wikiNodeToResource(spaceID string, node wikiNode) types.Resource { ExternalID: makeWikiNodeResourceID(spaceID, node.NodeToken), Name: name, Type: "wiki_node", - URL: fmt.Sprintf("https://feishu.cn/wiki/%s", node.NodeToken), + URL: c.region.wikiURL(node.NodeToken), ParentID: parentID, HasChildren: node.HasChild, ModifiedAt: modifiedAt, @@ -480,8 +484,13 @@ func wikiNodeToResource(spaceID string, node wikiNode) types.Resource { } } -// parseFeishuConfig extracts and validates Feishu-specific configuration. -func parseFeishuConfig(config *types.DataSourceConfig) (*Config, error) { +// parseFeishuConfig extracts and validates Feishu/Lark-specific configuration. +// +// base_url stays an explicit override so existing data sources that pointed a +// "feishu" connector at open.larksuite.com keep working; when it is unset the +// region's own host is filled in, making the resolved Config.BaseURL concrete +// for everything downstream. +func parseFeishuConfig(config *types.DataSourceConfig, region Region) (*Config, error) { if config == nil { return nil, fmt.Errorf("config is nil") } @@ -493,11 +502,15 @@ func parseFeishuConfig(config *types.DataSourceConfig) (*Config, error) { var feishuConfig Config if err := json.Unmarshal(credBytes, &feishuConfig); err != nil { - return nil, fmt.Errorf("parse feishu credentials: %w", err) + return nil, fmt.Errorf("parse %s credentials: %w", region.ConnectorType, err) } if feishuConfig.AppID == "" || feishuConfig.AppSecret == "" { - return nil, fmt.Errorf("feishu app_id and app_secret are required") + return nil, fmt.Errorf("%s app_id and app_secret are required", region.ConnectorType) + } + + if feishuConfig.BaseURL == "" { + feishuConfig.BaseURL = region.OpenBaseURL } if err := datasource.ValidateConnectorBaseURL(feishuConfig.GetBaseURL()); err != nil { diff --git a/internal/datasource/connector/feishu/connector_test.go b/internal/datasource/connector/feishu/connector_test.go index e3fa3b8f2..5d9dcf375 100644 --- a/internal/datasource/connector/feishu/connector_test.go +++ b/internal/datasource/connector/feishu/connector_test.go @@ -384,7 +384,7 @@ func TestParseFeishuConfig(t *testing.T) { "app_secret": "sec1", "base_url": "https://open.feishu.cn", }, - }) + }, RegionFeishu) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -394,7 +394,7 @@ func TestParseFeishuConfig(t *testing.T) { }) t.Run("nil config", func(t *testing.T) { - _, err := parseFeishuConfig(nil) + _, err := parseFeishuConfig(nil, RegionFeishu) if err == nil { t.Fatal("expected error for nil config") } @@ -406,7 +406,7 @@ func TestParseFeishuConfig(t *testing.T) { "app_id": "id1", // missing app_secret }, - }) + }, RegionFeishu) if err == nil { t.Fatal("expected error for missing app_secret") } @@ -418,7 +418,7 @@ func TestParseFeishuConfig(t *testing.T) { // ────────────────────────────────────────────────────────────────────── func TestConnectorType(t *testing.T) { - c := NewConnector() + c := NewConnector(RegionFeishu) if c.Type() != types.ConnectorTypeFeishu { t.Errorf("Type() = %q, want %q", c.Type(), types.ConnectorTypeFeishu) } @@ -428,7 +428,7 @@ func TestConnectorValidate(t *testing.T) { ts, cfg := fakeFeishu(nil) defer ts.Close() - c := NewConnector() + c := NewConnector(RegionFeishu) err := c.Validate(context.Background(), makeConfig(cfg, nil)) if err != nil { t.Fatalf("Validate() error: %v", err) @@ -436,7 +436,7 @@ func TestConnectorValidate(t *testing.T) { } func TestConnectorValidate_BadCredentials(t *testing.T) { - c := NewConnector() + c := NewConnector(RegionFeishu) err := c.Validate(context.Background(), &types.DataSourceConfig{ Credentials: map[string]interface{}{ "app_id": "bad", @@ -453,7 +453,7 @@ func TestConnectorListResources(t *testing.T) { ts, cfg := fakeFeishu(nil) defer ts.Close() - c := NewConnector() + c := NewConnector(RegionFeishu) resources, err := c.ListResources(context.Background(), makeConfig(cfg, nil), "") if err != nil { t.Fatalf("ListResources() error: %v", err) @@ -492,7 +492,7 @@ func TestConnectorListResources_LazyLoadsOneLevel(t *testing.T) { ts, cfg := fakeFeishuHierarchy(topNodes, childNodes, "") defer ts.Close() - c := NewConnector() + c := NewConnector(RegionFeishu) // Root listing: only the space, no descendants. spaces, err := c.ListResources(context.Background(), makeConfig(cfg, nil), "") @@ -555,7 +555,7 @@ func TestConnectorResolveResourceAncestors(t *testing.T) { ts, cfg := fakeFeishuHierarchy(topNodes, childNodes, "") defer ts.Close() - c := NewConnector() + c := NewConnector(RegionFeishu) // A deeply nested node resolves to its space plus every intermediate parent. ancestors, err := c.ResolveResourceAncestors( @@ -615,7 +615,7 @@ func TestFetchAll_DocxNode(t *testing.T) { ts, cfg := fakeFeishu(nodes) defer ts.Close() - c := NewConnector() + c := NewConnector(RegionFeishu) items, err := c.FetchAll(context.Background(), makeConfig(cfg, []string{"space1"}), []string{"space1"}) if err != nil { t.Fatalf("FetchAll() error: %v", err) @@ -657,7 +657,7 @@ func TestFetchAll_SheetNode(t *testing.T) { ts, cfg := fakeFeishu(nodes) defer ts.Close() - c := NewConnector() + c := NewConnector(RegionFeishu) items, err := c.FetchAll(context.Background(), makeConfig(cfg, []string{"space1"}), []string{"space1"}) if err != nil { t.Fatalf("FetchAll() error: %v", err) @@ -682,7 +682,7 @@ func TestFetchAll_BitableNode(t *testing.T) { ts, cfg := fakeFeishu(nodes) defer ts.Close() - c := NewConnector() + c := NewConnector(RegionFeishu) items, err := c.FetchAll(context.Background(), makeConfig(cfg, []string{"space1"}), []string{"space1"}) if err != nil { t.Fatalf("FetchAll() error: %v", err) @@ -706,7 +706,7 @@ func TestFetchAll_FileNode(t *testing.T) { ts, cfg := fakeFeishu(nodes) defer ts.Close() - c := NewConnector() + c := NewConnector(RegionFeishu) items, err := c.FetchAll(context.Background(), makeConfig(cfg, []string{"space1"}), []string{"space1"}) if err != nil { t.Fatalf("FetchAll() error: %v", err) @@ -736,7 +736,7 @@ func TestFetchAll_SkipsMindnoteAndSlides(t *testing.T) { ts, cfg := fakeFeishu(nodes) defer ts.Close() - c := NewConnector() + c := NewConnector(RegionFeishu) items, err := c.FetchAll(context.Background(), makeConfig(cfg, []string{"space1"}), []string{"space1"}) if err != nil { t.Fatalf("FetchAll() error: %v", err) @@ -760,7 +760,7 @@ func TestFetchAll_MixedTypes(t *testing.T) { ts, cfg := fakeFeishu(nodes) defer ts.Close() - c := NewConnector() + c := NewConnector(RegionFeishu) items, err := c.FetchAll(context.Background(), makeConfig(cfg, []string{"space1"}), []string{"space1"}) if err != nil { t.Fatalf("FetchAll() error: %v", err) @@ -783,7 +783,8 @@ func TestFetchAll_ChildNodeListErrorReturnsPartialItems(t *testing.T) { ts, cfg := fakeFeishuWithChildFailure(nodes, "nt-parent") defer ts.Close() - items, err := NewConnector().FetchAll(context.Background(), makeConfig(cfg, []string{"space1"}), []string{"space1"}) + conn := NewConnector(RegionFeishu) + items, err := conn.FetchAll(context.Background(), makeConfig(cfg, []string{"space1"}), []string{"space1"}) if err != nil { t.Fatalf("FetchAll must not abort when one child listing fails: %v", err) } @@ -838,7 +839,8 @@ func TestFetchAll_WikiNodeResourceSyncsSelectedSubtree(t *testing.T) { defer ts.Close() resourceID := "space1:nt-root" - items, err := NewConnector().FetchAll(context.Background(), makeConfig(cfg, []string{resourceID}), []string{resourceID}) + conn := NewConnector(RegionFeishu) + items, err := conn.FetchAll(context.Background(), makeConfig(cfg, []string{resourceID}), []string{resourceID}) if err != nil { t.Fatalf("FetchAll() error: %v", err) } @@ -876,7 +878,7 @@ func TestFetchIncremental_FirstSync(t *testing.T) { ts, cfg := fakeFeishu(nodes) defer ts.Close() - c := NewConnector() + c := NewConnector(RegionFeishu) dsConfig := makeConfig(cfg, []string{"space1"}) // First sync with no cursor → all items should be fetched @@ -903,7 +905,7 @@ func TestFetchIncremental_NoChanges(t *testing.T) { ts, cfg := fakeFeishu(nodes) defer ts.Close() - c := NewConnector() + c := NewConnector(RegionFeishu) dsConfig := makeConfig(cfg, []string{"space1"}) // First sync @@ -931,7 +933,7 @@ func TestFetchIncremental_DetectsDeleted(t *testing.T) { } ts, cfg := fakeFeishu(allNodes) - c := NewConnector() + c := NewConnector(RegionFeishu) dsConfig := makeConfig(cfg, []string{"space1"}) _, cursor1, err := c.FetchIncremental(context.Background(), dsConfig, nil) @@ -971,7 +973,7 @@ func TestFetchIncremental_NoResourceIDs(t *testing.T) { ts, cfg := fakeFeishu(nil) defer ts.Close() - c := NewConnector() + c := NewConnector(RegionFeishu) dsConfig := makeConfig(cfg, nil) // no resource IDs dsConfig.ResourceIDs = nil @@ -992,7 +994,8 @@ func TestFetchIncremental_ChildNodeListErrorReturnsPartialItemsAndCursor(t *test ts, cfg := fakeFeishuWithChildFailure(nodes, "nt-parent") defer ts.Close() - items, cursor, err := NewConnector().FetchIncremental(context.Background(), makeConfig(cfg, []string{"space1"}), nil) + conn := NewConnector(RegionFeishu) + items, cursor, err := conn.FetchIncremental(context.Background(), makeConfig(cfg, []string{"space1"}), nil) if err != nil { t.Fatalf("FetchIncremental must not abort when one child listing fails: %v", err) } @@ -1029,7 +1032,8 @@ func TestFetchIncremental_ChildNodeListErrorDoesNotDeletePreviouslySeenChildren( } ts, cfg := fakeFeishuHierarchy(firstNodes, firstChildren, "") - firstItems, cursor, err := NewConnector().FetchIncremental(context.Background(), makeConfig(cfg, []string{"space1"}), nil) + conn := NewConnector(RegionFeishu) + firstItems, cursor, err := conn.FetchIncremental(context.Background(), makeConfig(cfg, []string{"space1"}), nil) if err != nil { t.Fatalf("first sync error: %v", err) } @@ -1044,7 +1048,7 @@ func TestFetchIncremental_ChildNodeListErrorDoesNotDeletePreviouslySeenChildren( ts2, cfg2 := fakeFeishuHierarchy(secondNodes, nil, "nt-parent") defer ts2.Close() - items, nextCursor, err := NewConnector().FetchIncremental(context.Background(), makeConfig(cfg2, []string{"space1"}), cursor) + items, nextCursor, err := conn.FetchIncremental(context.Background(), makeConfig(cfg2, []string{"space1"}), cursor) if err != nil { t.Fatalf("partial second sync error: %v", err) } diff --git a/internal/datasource/connector/feishu/lark_e2e_test.go b/internal/datasource/connector/feishu/lark_e2e_test.go new file mode 100644 index 000000000..a3b25f5dd --- /dev/null +++ b/internal/datasource/connector/feishu/lark_e2e_test.go @@ -0,0 +1,86 @@ +package feishu + +import ( + "context" + "strings" + "testing" + + "github.com/Tencent/WeKnora/internal/types" +) + +// A Lark connector must surface wiki links on the Lark host. Before Lark became +// its own connector type, every resource URL was hardcoded to feishu.cn, so Lark +// users got links into a cloud their content does not live on. +// +// The fake server stands in for the Open Platform (base_url points at it), but +// the resource URL is built from the region's web host, so this exercises the +// real ListResources → wikiNodeToResource → region.wikiURL path. +func TestListResources_ResourceURLFollowsRegion(t *testing.T) { + cases := []struct { + region Region + wantURL string + forbiddenIn string + }{ + {RegionFeishu, "https://feishu.cn/wiki/space1", "larksuite"}, + {RegionLark, "https://larksuite.com/wiki/space1", "feishu"}, + } + + for _, c := range cases { + t.Run(c.region.Label, func(t *testing.T) { + ts, cfg := fakeFeishu(nil) + defer ts.Close() + + conn := NewConnector(c.region) + resources, err := conn.ListResources(context.Background(), makeConfig(cfg, nil), "") + if err != nil { + t.Fatalf("ListResources: %v", err) + } + if len(resources) != 1 { + t.Fatalf("got %d resources, want 1", len(resources)) + } + if got := resources[0].URL; got != c.wantURL { + t.Errorf("resource URL = %q, want %q", got, c.wantURL) + } + if strings.Contains(resources[0].URL, c.forbiddenIn) { + t.Errorf("%s resource URL leaks the other cloud's host: %q", c.region.Label, resources[0].URL) + } + }) + } +} + +// A Lark connector with no base_url override must reach open.larksuite.com, +// never open.feishu.cn — the SDK-less client has no default of its own, so the +// region is the only thing standing between a Lark app and the wrong cloud. +func TestClient_ResolvesRegionHostWithoutOverride(t *testing.T) { + cases := []struct { + region Region + want string + }{ + {RegionFeishu, "https://open.feishu.cn"}, + {RegionLark, "https://open.larksuite.com"}, + } + + for _, c := range cases { + t.Run(c.region.Label, func(t *testing.T) { + cfg, err := parseFeishuConfig(makeConfigNoBaseURL(), c.region) + if err != nil { + t.Fatalf("parseFeishuConfig: %v", err) + } + client := NewClient(cfg) + if client.baseURL != c.want { + t.Errorf("client baseURL = %q, want %q", client.baseURL, c.want) + } + }) + } +} + +// makeConfigNoBaseURL builds credentials without base_url, so the region default +// is what resolves. +func makeConfigNoBaseURL() *types.DataSourceConfig { + return &types.DataSourceConfig{ + Credentials: map[string]interface{}{ + "app_id": "cli_x", + "app_secret": "s", + }, + } +} diff --git a/internal/datasource/connector/feishu/region.go b/internal/datasource/connector/feishu/region.go new file mode 100644 index 000000000..b2898dcf0 --- /dev/null +++ b/internal/datasource/connector/feishu/region.go @@ -0,0 +1,57 @@ +package feishu + +import "github.com/Tencent/WeKnora/internal/types" + +// Open Platform API origins. Feishu and Lark are the same product deployed on +// two isolated clouds; the wiki/docx/drive APIs this connector uses are +// identical, only the host differs. +const ( + feishuOpenBaseURL = "https://open.feishu.cn" + larkOpenBaseURL = "https://open.larksuite.com" +) + +// Web origins used to build human-facing links to wiki spaces and nodes. These +// are the end-user app hosts, not the API hosts. +const ( + feishuWebBaseURL = "https://feishu.cn" + larkWebBaseURL = "https://larksuite.com" +) + +// Region selects which cloud the connector syncs from. Apps, tenants, tokens +// and document tokens are scoped to a single cloud and are never valid on the +// other, so a Region is fixed per connector instance at registration. +type Region struct { + // ConnectorType is the types.ConnectorType* identifier this instance serves. + ConnectorType string + // OpenBaseURL is the Open Platform API origin, without a trailing slash. + // It is only the default: a data source may override it via base_url. + OpenBaseURL string + // WebBaseURL is the app origin used to build wiki links shown to users. + WebBaseURL string + // Label prefixes log lines so operators can tell the clouds apart. + Label string +} + +var ( + // RegionFeishu is the Chinese mainland cloud (飞书). + RegionFeishu = Region{ + ConnectorType: types.ConnectorTypeFeishu, + OpenBaseURL: feishuOpenBaseURL, + WebBaseURL: feishuWebBaseURL, + Label: "Feishu", + } + + // RegionLark is the international cloud (Lark). + RegionLark = Region{ + ConnectorType: types.ConnectorTypeLark, + OpenBaseURL: larkOpenBaseURL, + WebBaseURL: larkWebBaseURL, + Label: "Lark", + } +) + +// wikiURL builds the user-facing link to a wiki space or node on this cloud. +// The token is either a space_id or a node_token; both live under /wiki/. +func (r Region) wikiURL(token string) string { + return r.WebBaseURL + "/wiki/" + token +} diff --git a/internal/datasource/connector/feishu/region_test.go b/internal/datasource/connector/feishu/region_test.go new file mode 100644 index 000000000..f6cbab899 --- /dev/null +++ b/internal/datasource/connector/feishu/region_test.go @@ -0,0 +1,82 @@ +package feishu + +import ( + "strings" + "testing" + + "github.com/Tencent/WeKnora/internal/types" +) + +func TestRegions_AreDistinct(t *testing.T) { + if RegionFeishu.ConnectorType == RegionLark.ConnectorType { + t.Errorf("regions share ConnectorType %q", RegionFeishu.ConnectorType) + } + if RegionFeishu.OpenBaseURL == RegionLark.OpenBaseURL { + t.Errorf("regions share OpenBaseURL %q", RegionFeishu.OpenBaseURL) + } + if RegionFeishu.WebBaseURL == RegionLark.WebBaseURL { + t.Errorf("regions share WebBaseURL %q", RegionFeishu.WebBaseURL) + } + if RegionFeishu.ConnectorType != types.ConnectorTypeFeishu { + t.Errorf("RegionFeishu.ConnectorType = %q, want %q", RegionFeishu.ConnectorType, types.ConnectorTypeFeishu) + } + if RegionLark.ConnectorType != types.ConnectorTypeLark { + t.Errorf("RegionLark.ConnectorType = %q, want %q", RegionLark.ConnectorType, types.ConnectorTypeLark) + } +} + +func TestConnectorType_FollowsRegion(t *testing.T) { + if got := NewConnector(RegionFeishu).Type(); got != types.ConnectorTypeFeishu { + t.Errorf("Feishu connector Type() = %q, want %q", got, types.ConnectorTypeFeishu) + } + if got := NewConnector(RegionLark).Type(); got != types.ConnectorTypeLark { + t.Errorf("Lark connector Type() = %q, want %q", got, types.ConnectorTypeLark) + } +} + +// The wiki link shown in the resource picker must point at the cloud the data +// actually lives on. Feishu's value is the pre-existing one and must not drift. +func TestRegion_WikiURL(t *testing.T) { + if got, want := RegionFeishu.wikiURL("spc123"), "https://feishu.cn/wiki/spc123"; got != want { + t.Errorf("Feishu wikiURL = %q, want %q", got, want) + } + if got, want := RegionLark.wikiURL("spc123"), "https://larksuite.com/wiki/spc123"; got != want { + t.Errorf("Lark wikiURL = %q, want %q", got, want) + } + // A Lark link must never point at the Feishu host — that was the bug. + if strings.Contains(RegionLark.wikiURL("x"), "feishu") { + t.Errorf("Lark wikiURL leaks the Feishu host: %q", RegionLark.wikiURL("x")) + } +} + +// base_url stays an override so data sources created before the lark connector +// existed — a "feishu" connector pointed at open.larksuite.com — keep working. +func TestParseFeishuConfig_BaseURLDefaultsToRegion(t *testing.T) { + cases := []struct { + name string + region Region + baseURL string + want string + }{ + {"feishu default", RegionFeishu, "", "https://open.feishu.cn"}, + {"lark default", RegionLark, "", "https://open.larksuite.com"}, + {"explicit override wins on feishu", RegionFeishu, "https://open.larksuite.com", "https://open.larksuite.com"}, + {"explicit override wins on lark", RegionLark, "https://open.feishu.cn", "https://open.feishu.cn"}, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + creds := map[string]interface{}{"app_id": "cli_x", "app_secret": "s"} + if c.baseURL != "" { + creds["base_url"] = c.baseURL + } + cfg, err := parseFeishuConfig(&types.DataSourceConfig{Credentials: creds}, c.region) + if err != nil { + t.Fatalf("parseFeishuConfig: %v", err) + } + if got := cfg.GetBaseURL(); got != c.want { + t.Errorf("GetBaseURL() = %q, want %q", got, c.want) + } + }) + } +} diff --git a/internal/handler/im.go b/internal/handler/im.go index b3c61d685..3bf793fcc 100644 --- a/internal/handler/im.go +++ b/internal/handler/im.go @@ -3,6 +3,7 @@ package handler import ( "context" "net/http" + "sort" "strings" "github.com/Tencent/WeKnora/internal/im" @@ -13,10 +14,22 @@ import ( // validIMPlatforms is the set of supported IM platforms. var validIMPlatforms = map[string]bool{ - "wecom": true, "feishu": true, "slack": true, "telegram": true, "dingtalk": true, "mattermost": true, - "wechat": true, "qqbot": true, + "wecom": true, "feishu": true, "lark": true, "slack": true, "telegram": true, "dingtalk": true, + "mattermost": true, "wechat": true, "qqbot": true, } +// invalidIMPlatformError is the 400 message listing the accepted platforms. It +// is derived from validIMPlatforms so the two cannot drift apart as platforms +// are added. +var invalidIMPlatformError = func() string { + names := make([]string, 0, len(validIMPlatforms)) + for p := range validIMPlatforms { + names = append(names, "'"+p+"'") + } + sort.Strings(names) + return "platform must be one of: " + strings.Join(names, ", ") +}() + // IMHandler handles IM platform callback requests and channel CRUD. type IMHandler struct { imService *im.Service @@ -60,7 +73,7 @@ func (h *IMHandler) CreateIMChannel(c *gin.Context) { } if !validIMPlatforms[req.Platform] { - c.JSON(http.StatusBadRequest, gin.H{"error": "platform must be 'wecom', 'feishu', 'slack', 'telegram', 'dingtalk', 'mattermost', 'wechat' or 'qqbot'"}) + c.JSON(http.StatusBadRequest, gin.H{"error": invalidIMPlatformError}) return } diff --git a/internal/handler/im_platform_test.go b/internal/handler/im_platform_test.go new file mode 100644 index 000000000..3cb4daed5 --- /dev/null +++ b/internal/handler/im_platform_test.go @@ -0,0 +1,33 @@ +package handler + +import ( + "strings" + "testing" +) + +// The channel-creation endpoint rejects any platform without a registered +// adapter factory, so this set must track the factories wired in the container. +func TestValidIMPlatforms_CoversLark(t *testing.T) { + want := []string{ + "wecom", "feishu", "lark", "slack", "telegram", + "dingtalk", "mattermost", "wechat", "qqbot", + } + for _, platform := range want { + if !validIMPlatforms[platform] { + t.Errorf("platform %q is not accepted", platform) + } + } + if validIMPlatforms["nonsense"] { + t.Error("unknown platform is accepted") + } +} + +// The 400 message is derived from validIMPlatforms; it must not drift as +// platforms are added. +func TestInvalidIMPlatformError_ListsEveryPlatform(t *testing.T) { + for platform := range validIMPlatforms { + if !strings.Contains(invalidIMPlatformError, "'"+platform+"'") { + t.Errorf("error message omits %q: %s", platform, invalidIMPlatformError) + } + } +} diff --git a/internal/im/adapter.go b/internal/im/adapter.go index 41ec2234a..034a28729 100644 --- a/internal/im/adapter.go +++ b/internal/im/adapter.go @@ -11,8 +11,11 @@ import ( type Platform string const ( - PlatformWeCom Platform = "wecom" - PlatformFeishu Platform = "feishu" + PlatformWeCom Platform = "wecom" + PlatformFeishu Platform = "feishu" + // PlatformLark is Feishu's international edition (open.larksuite.com). + // It shares the Feishu adapter; only the API host and tenant differ. + PlatformLark Platform = "lark" PlatformSlack Platform = "slack" PlatformTelegram Platform = "telegram" PlatformDingtalk Platform = "dingtalk" @@ -67,7 +70,7 @@ type IncomingMessage struct { // ThreadID is the platform-specific thread identifier. // - Slack: thread_ts (top-level message uses its own timestamp) // - Mattermost: root_id, or post_id if top-level - // - Feishu: root_id, or message_id if top-level + // - Feishu/Lark: root_id, or message_id if top-level // - Telegram: message_thread_id (Forum Topics only) // Empty for platforms without thread support (WeCom, DingTalk). // In thread mode, top-level messages use their own ID as ThreadID, diff --git a/internal/im/channel_mapping_test.go b/internal/im/channel_mapping_test.go new file mode 100644 index 000000000..7d3bc2754 --- /dev/null +++ b/internal/im/channel_mapping_test.go @@ -0,0 +1,34 @@ +package im + +import ( + "testing" + + "github.com/Tencent/WeKnora/internal/types" +) + +// Files uploaded through an IM channel are tagged with a knowledge Channel. +// Lark deliberately shares Feishu's channel (types.ChannelFeishu documents +// itself as "Feishu / Lark"), so Lark uploads group with Feishu ones in the KB +// UI rather than falling through to the generic "im" channel. +func TestIMPlatformToChannel(t *testing.T) { + cases := map[string]string{ + "feishu": types.ChannelFeishu, + "lark": types.ChannelFeishu, + "Lark": types.ChannelFeishu, // matching is case-insensitive + "wechat": types.ChannelWechat, + "wecom": types.ChannelWecom, + "wxwork": types.ChannelWecom, + + "dingtalk": types.ChannelDingtalk, + "slack": types.ChannelSlack, + // Platforms without a dedicated channel fall back to the generic one. + "telegram": types.ChannelIM, + "": types.ChannelIM, + } + + for platform, want := range cases { + if got := imPlatformToChannel(platform); got != want { + t.Errorf("imPlatformToChannel(%q) = %q, want %q", platform, got, want) + } + } +} diff --git a/internal/im/feishu/adapter.go b/internal/im/feishu/adapter.go index 6bee2d826..0b7449542 100644 --- a/internal/im/feishu/adapter.go +++ b/internal/im/feishu/adapter.go @@ -1,9 +1,12 @@ -// Package feishu implements the Feishu (飞书/Lark) IM adapter for WeKnora. +// Package feishu implements the Feishu (飞书) IM adapter for WeKnora, and with +// it the Lark adapter: Feishu and Lark are the same product on two isolated +// clouds (open.feishu.cn and open.larksuite.com) sharing one API surface, so a +// single implementation serves both. A Region picks the cloud — see region.go. // -// Feishu bot flow: +// Bot flow: // 1. User sends a message to the bot (direct or @mention in group) -// 2. Feishu calls our event subscription URL with the message event -// 3. We parse the event, run QA, then call Feishu API to send reply +// 2. Feishu/Lark calls our event subscription URL with the message event +// 3. We parse the event, run QA, then call the Open Platform API to send a reply // 4. For streaming: create a card, then use CardKit streaming update API // // Reference: https://open.feishu.cn/document/server-docs/im-v1/message/create @@ -40,6 +43,7 @@ var httpClient = &http.Client{Timeout: 10 * time.Second} // Adapter implements im.Adapter for Feishu/Lark. type Adapter struct { + region Region appID string appSecret string verificationToken string @@ -51,10 +55,11 @@ type Adapter struct { tokenExpAt time.Time } -// NewAdapter creates a new Feishu adapter. -func NewAdapter(appID, appSecret, verificationToken, encryptKey string) *Adapter { +// NewAdapter creates a new adapter for the given region (RegionFeishu or RegionLark). +func NewAdapter(region Region, appID, appSecret, verificationToken, encryptKey string) *Adapter { startStreamReaper() return &Adapter{ + region: region, appID: appID, appSecret: appSecret, verificationToken: verificationToken, @@ -62,6 +67,12 @@ func NewAdapter(appID, appSecret, verificationToken, encryptKey string) *Adapter } } +// api builds an Open Platform API URL on this adapter's cloud. path is a format +// string beginning with "/open-apis/"; args fill its verbs. +func (a *Adapter) api(path string, args ...any) string { + return a.region.OpenBaseURL + fmt.Sprintf(path, args...) +} + // startStreamReaper starts a background goroutine (once) that periodically // removes orphaned stream entries from feishuStreams. This prevents memory // leaks when EndStream is never called due to panics or pipeline errors. @@ -102,7 +113,7 @@ func StopStreamReaper() { // Platform returns the platform identifier. func (a *Adapter) Platform() im.Platform { - return im.PlatformFeishu + return a.region.Platform } // VerifyCallback verifies the Feishu event callback by checking the verification token. @@ -167,7 +178,7 @@ func (a *Adapter) HandleURLVerification(c *gin.Context) bool { if err := json.Unmarshal(bodyBytes, &encryptedBody); err == nil && encryptedBody.Encrypt != "" { decrypted, err := a.decrypt(encryptedBody.Encrypt) if err != nil { - logger.Errorf(c.Request.Context(), "[Feishu] Failed to decrypt: %v", err) + logger.Errorf(c.Request.Context(), "[%s] Failed to decrypt: %v", a.region.Label, err) return false } if err := json.Unmarshal(decrypted, &body); err != nil { @@ -257,7 +268,7 @@ func (a *Adapter) ParseCallback(c *gin.Context) (*im.IncomingMessage, error) { // Check event type if eventBody.Header == nil || eventBody.Header.EventType != "im.message.receive_v1" { if eventBody.Header != nil { - logger.Infof(c.Request.Context(), "[Feishu] Ignoring event type: %s", eventBody.Header.EventType) + logger.Infof(c.Request.Context(), "[%s] Ignoring event type: %s", a.region.Label, eventBody.Header.EventType) } return nil, nil } @@ -312,7 +323,7 @@ func (a *Adapter) ParseCallback(c *gin.Context) (*im.IncomingMessage, error) { } return &im.IncomingMessage{ - Platform: im.PlatformFeishu, + Platform: a.region.Platform, MessageType: im.MessageTypeText, UserID: openID, ChatID: chatID, @@ -334,7 +345,7 @@ func (a *Adapter) ParseCallback(c *gin.Context) (*im.IncomingMessage, error) { return nil, nil } return &im.IncomingMessage{ - Platform: im.PlatformFeishu, + Platform: a.region.Platform, MessageType: im.MessageTypeFile, UserID: openID, ChatID: chatID, @@ -356,7 +367,7 @@ func (a *Adapter) ParseCallback(c *gin.Context) (*im.IncomingMessage, error) { return nil, nil } return &im.IncomingMessage{ - Platform: im.PlatformFeishu, + Platform: a.region.Platform, MessageType: im.MessageTypeImage, UserID: openID, ChatID: chatID, @@ -418,7 +429,7 @@ func (a *Adapter) ParseCallback(c *gin.Context) (*im.IncomingMessage, error) { } return &im.IncomingMessage{ - Platform: im.PlatformFeishu, + Platform: a.region.Platform, MessageType: im.MessageTypeText, UserID: openID, ChatID: chatID, @@ -429,7 +440,7 @@ func (a *Adapter) ParseCallback(c *gin.Context) (*im.IncomingMessage, error) { }, nil default: - logger.Infof(c.Request.Context(), "[Feishu] Ignoring unsupported message type: %s", msg.MessageType) + logger.Infof(c.Request.Context(), "[%s] Ignoring unsupported message type: %s", a.region.Label, msg.MessageType) return nil, nil } } @@ -514,20 +525,21 @@ func (a *Adapter) sendWithFallback( // some event payloads omit it), skip straight to the fallback send API — // the reply API needs a message_id in the path and can't work without one. if incoming.MessageID != "" && feishuSafePathParam(incoming.MessageID) { - replyURL := fmt.Sprintf("https://open.feishu.cn/open-apis/im/v1/messages/%s/reply", incoming.MessageID) + replyURL := a.api("/open-apis/im/v1/messages/%s/reply", incoming.MessageID) code, msg, err := a.postFeishuMessage(ctx, accessToken, replyURL, replyPayload) switch { case err != nil: // Network/transport error — try the fallback since send-message is // a different endpoint that might succeed. - logger.Warnf(ctx, "[Feishu] reply API transport error (will try fallback): %v", err) + logger.Warnf(ctx, "[%s] reply API transport error (will try fallback): %v", a.region.Label, err) case code == 0: return nil case !fallbackEligibleErrorCodes[code]: - return fmt.Errorf("feishu reply api error: code=%d msg=%s", code, msg) + return fmt.Errorf("%s reply api error: code=%d msg=%s", a.region.Label, code, msg) default: // Fallback-eligible: retry via plain send-message API. - logger.Warnf(ctx, "[Feishu] reply API returned code=%d msg=%s, falling back to send-message API", code, msg) + logger.Warnf(ctx, "[%s] reply API returned code=%d msg=%s, falling back to send-message API", + a.region.Label, code, msg) } } else if incoming.MessageID != "" { // message_id present but contains unsafe characters — refuse rather @@ -535,17 +547,19 @@ func (a *Adapter) sendWithFallback( // id suggests a tampered payload. return fmt.Errorf("invalid message_id for reply API: %q", incoming.MessageID) } else { - logger.Warnf(ctx, "[Feishu] incoming message has no message_id; replying via send-message API (will not attach to thread)") + logger.Warnf(ctx, + "[%s] incoming message has no message_id; replying via send-message API (will not attach to thread)", + a.region.Label) } // Fallback: plain send-message API. - fallbackURL := fmt.Sprintf("https://open.feishu.cn/open-apis/im/v1/messages?receive_id_type=%s", receiveIDType) + fallbackURL := a.api("/open-apis/im/v1/messages?receive_id_type=%s", receiveIDType) code, msg, err := a.postFeishuMessage(ctx, accessToken, fallbackURL, fallbackPayload) if err != nil { return fmt.Errorf("send message (fallback): %w", err) } if code != 0 { - return fmt.Errorf("feishu send api error: code=%d msg=%s", code, msg) + return fmt.Errorf("%s send api error: code=%d msg=%s", a.region.Label, code, msg) } return nil } @@ -616,7 +630,7 @@ func (a *Adapter) DownloadFile(ctx context.Context, msg *im.IncomingMessage) (io resourceType = "image" } - apiURL := fmt.Sprintf("https://open.feishu.cn/open-apis/im/v1/messages/%s/resources/%s?type=%s", + apiURL := a.api("/open-apis/im/v1/messages/%s/resources/%s?type=%s", msg.MessageID, msg.FileKey, resourceType) req, err := http.NewRequestWithContext(ctx, http.MethodGet, apiURL, nil) @@ -700,12 +714,14 @@ func (s *feishuStreamState) nextSeq() int { } // buildStreamingCardJSON builds a Card JSON 2.0 with streaming_mode enabled. -func buildStreamingCardJSON() string { +// The placeholder text follows the region so Lark users see English rather than +// the Chinese wording Feishu users expect. +func buildStreamingCardJSON(region Region) string { card := map[string]interface{}{ "schema": "2.0", "config": map[string]interface{}{ "streaming_mode": true, - "summary": map[string]string{"content": "正在思考..."}, + "summary": map[string]string{"content": region.ThinkingText}, }, "header": map[string]interface{}{ "template": "blue", @@ -715,7 +731,7 @@ func buildStreamingCardJSON() string { "elements": []map[string]interface{}{ { "tag": "markdown", - "content": "💭 正在思考...", + "content": "💭 " + region.ThinkingText, "text_size": "normal", "element_id": streamingElementID, }, @@ -734,7 +750,7 @@ func (a *Adapter) StartStream(ctx context.Context, incoming *im.IncomingMessage) } // 1. Create card entity via CardKit API - cardJSON := buildStreamingCardJSON() + cardJSON := buildStreamingCardJSON(a.region) cardID, err := a.cardkitCreate(ctx, accessToken, cardJSON) if err != nil { return "", fmt.Errorf("create card: %w", err) @@ -750,7 +766,7 @@ func (a *Adapter) StartStream(ctx context.Context, incoming *im.IncomingMessage) feishuStreams[cardID] = &feishuStreamState{createdAt: time.Now()} feishuStreamsMu.Unlock() - logger.Infof(ctx, "[Feishu] Streaming started: card_id=%s", cardID) + logger.Infof(ctx, "[%s] Streaming started: card_id=%s", a.region.Label, cardID) return cardID, nil } @@ -820,10 +836,10 @@ func (a *Adapter) EndStream(ctx context.Context, incoming *im.IncomingMessage, s // Turn off streaming_mode to remove loading indicator if err := a.cardkitSetStreaming(ctx, accessToken, streamID, false, seq); err != nil { - logger.Warnf(ctx, "[Feishu] Failed to disable streaming_mode: %v", err) + logger.Warnf(ctx, "[%s] Failed to disable streaming_mode: %v", a.region.Label, err) } - logger.Infof(ctx, "[Feishu] Streaming ended: card_id=%s", streamID) + logger.Infof(ctx, "[%s] Streaming ended: card_id=%s", a.region.Label, streamID) return nil } @@ -838,7 +854,7 @@ func (a *Adapter) cardkitCreate(ctx context.Context, accessToken, cardJSON strin }) req, err := http.NewRequestWithContext(ctx, http.MethodPost, - "https://open.feishu.cn/open-apis/cardkit/v1/cards", bytes.NewReader(payload)) + a.api("/open-apis/cardkit/v1/cards"), bytes.NewReader(payload)) if err != nil { return "", err } @@ -915,8 +931,7 @@ func (a *Adapter) cardkitUpdateElement(ctx context.Context, accessToken, cardID, "sequence": sequence, }) - apiURL := fmt.Sprintf("https://open.feishu.cn/open-apis/cardkit/v1/cards/%s/elements/%s/content", - cardID, elementID) + apiURL := a.api("/open-apis/cardkit/v1/cards/%s/elements/%s/content", cardID, elementID) req, err := http.NewRequestWithContext(ctx, http.MethodPut, apiURL, bytes.NewReader(payload)) if err != nil { return err @@ -991,10 +1006,10 @@ func (a *Adapter) resolveMarkdownImages(ctx context.Context, accessToken, conten alt, rawURL := sub[1], sub[2] imgKey, err := a.imageKeyForURL(ctx, accessToken, rawURL) if err != nil || imgKey == "" { - logger.Warnf(ctx, "[Feishu] image upload failed, degrading to link: url=%s err=%v", rawURL, err) + logger.Warnf(ctx, "[%s] image upload failed, degrading to link: url=%s err=%v", a.region.Label, rawURL, err) label := alt if label == "" { - label = "图片" + label = a.region.ImageFallbackLabel } return fmt.Sprintf("[%s](%s)", label, rawURL) } @@ -1005,7 +1020,10 @@ func (a *Adapter) resolveMarkdownImages(ctx context.Context, accessToken, conten // imageKeyForURL returns a Feishu image_key for the given URL, uploading it if // not already cached. func (a *Adapter) imageKeyForURL(ctx context.Context, accessToken, rawURL string) (string, error) { - key := imageCacheKey(rawURL) + // image_keys are issued per app on a single cloud. Scope the cache by app so + // a key uploaded by a Feishu app is never handed to a Lark app (or to another + // tenant's app), which would fail the card update with code=200570. + key := a.appID + "\x00" + imageCacheKey(rawURL) feishuImageKeyMu.Lock() if v, ok := feishuImageKeyCache[key]; ok { @@ -1074,7 +1092,7 @@ func (a *Adapter) uploadImageFromURL(ctx context.Context, accessToken, rawURL st } req, err := http.NewRequestWithContext(ctx, http.MethodPost, - "https://open.feishu.cn/open-apis/im/v1/images", &body) + a.api("/open-apis/im/v1/images"), &body) if err != nil { return "", err } @@ -1117,7 +1135,7 @@ func (a *Adapter) cardkitSetStreaming(ctx context.Context, accessToken, cardID s "sequence": sequence, }) - apiURL := fmt.Sprintf("https://open.feishu.cn/open-apis/cardkit/v1/cards/%s/settings", cardID) + apiURL := a.api("/open-apis/cardkit/v1/cards/%s/settings", cardID) req, err := http.NewRequestWithContext(ctx, http.MethodPatch, apiURL, bytes.NewReader(payload)) if err != nil { return err @@ -1160,7 +1178,7 @@ func (a *Adapter) getTenantAccessToken(ctx context.Context) (string, error) { }) req, err := http.NewRequestWithContext(ctx, http.MethodPost, - "https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal", + a.api("/open-apis/auth/v3/tenant_access_token/internal"), bytes.NewReader(payload)) if err != nil { return "", fmt.Errorf("create request: %w", err) diff --git a/internal/im/feishu/adapter_test.go b/internal/im/feishu/adapter_test.go index 8eb084027..3e6e577da 100644 --- a/internal/im/feishu/adapter_test.go +++ b/internal/im/feishu/adapter_test.go @@ -69,7 +69,7 @@ func TestImageCacheKey_StripsQuery(t *testing.T) { } func TestResolveMarkdownImages_NoImageUnchanged(t *testing.T) { - a := &Adapter{} + a := &Adapter{region: RegionFeishu} in := "hello **world** [link](https://example.com)" if got := a.resolveMarkdownImages(context.Background(), "tok", in); got != in { t.Errorf("content without image was modified: %q", got) @@ -77,7 +77,7 @@ func TestResolveMarkdownImages_NoImageUnchanged(t *testing.T) { } func TestResolveMarkdownImages_FallbackToLinkOnFailure(t *testing.T) { - a := &Adapter{} + a := &Adapter{region: RegionFeishu} // A direct-IP loopback URL fails SSRF validation before any network call, // so the image must degrade to a plain markdown link (never left as ![]()). in := "see  here" @@ -89,3 +89,16 @@ func TestResolveMarkdownImages_FallbackToLinkOnFailure(t *testing.T) { t.Errorf("expected link fallback with alt text, got: %q", got) } } + +// An image with no alt text falls back to the region's own label, so Lark users +// do not get a Chinese link label. +func TestResolveMarkdownImages_FallbackLabelFollowsRegion(t *testing.T) { + for _, region := range []Region{RegionFeishu, RegionLark} { + a := &Adapter{region: region} + got := a.resolveMarkdownImages(context.Background(), "tok", "") + want := "[" + region.ImageFallbackLabel + "](http://127.0.0.1/x.png)" + if got != want { + t.Errorf("%s fallback = %q, want %q", region.Label, got, want) + } + } +} diff --git a/internal/im/feishu/factory.go b/internal/im/feishu/factory.go index 24bee0bd9..b40501cec 100644 --- a/internal/im/feishu/factory.go +++ b/internal/im/feishu/factory.go @@ -8,14 +8,17 @@ import ( "github.com/Tencent/WeKnora/internal/logger" ) -// NewFactory returns an im.AdapterFactory for Feishu (Lark) channels. +// NewFactory returns an im.AdapterFactory for channels on the given region's +// cloud — RegionFeishu for 飞书, RegionLark for Lark. Both use the same +// credentials and modes; only the API host and tenant differ. +// // The HTTP adapter is always created (needed for SendReply in both modes); // "websocket" mode additionally runs a long-connection event stream. -func NewFactory() im.AdapterFactory { +func NewFactory(region Region) im.AdapterFactory { return func(factoryCtx context.Context, channel *im.IMChannel, msgHandler func(context.Context, *im.IncomingMessage) error) (im.Adapter, context.CancelFunc, error) { creds, err := im.ParseCredentials(channel.Credentials) if err != nil { - return nil, nil, fmt.Errorf("parse feishu credentials: %w", err) + return nil, nil, fmt.Errorf("parse %s credentials: %w", region.Platform, err) } appID := im.GetString(creds, "app_id") @@ -24,7 +27,7 @@ func NewFactory() im.AdapterFactory { encryptKey := im.GetString(creds, "encrypt_key") // Always create the HTTP adapter (needed for SendReply in both modes) - adapter := NewAdapter(appID, appSecret, verificationToken, encryptKey) + adapter := NewAdapter(region, appID, appSecret, verificationToken, encryptKey) mode := im.ResolveMode(channel, "websocket") @@ -33,12 +36,13 @@ func NewFactory() im.AdapterFactory { return adapter, nil, nil case "websocket": - client := NewLongConnClient(appID, appSecret, msgHandler) + client := NewLongConnClient(region, appID, appSecret, msgHandler) wsCtx, wsCancel := context.WithCancel(context.Background()) go func() { if err := client.Start(wsCtx); err != nil && wsCtx.Err() == nil { - logger.Errorf(context.Background(), "[IM] Feishu long connection stopped for channel %s: %v", channel.ID, err) + logger.Errorf(context.Background(), "[IM] %s long connection stopped for channel %s: %v", + region.Label, channel.ID, err) } }() @@ -54,7 +58,7 @@ func NewFactory() im.AdapterFactory { return adapter, stop, nil default: - return nil, nil, fmt.Errorf("unknown Feishu mode: %s", mode) + return nil, nil, fmt.Errorf("unknown %s mode: %s", region.Platform, mode) } } } diff --git a/internal/im/feishu/longconn.go b/internal/im/feishu/longconn.go index 806bc9e7c..342f42b1d 100644 --- a/internal/im/feishu/longconn.go +++ b/internal/im/feishu/longconn.go @@ -16,46 +16,50 @@ import ( // MessageHandler is called when an IM message is received via long connection. type MessageHandler func(ctx context.Context, msg *im.IncomingMessage) error -// LongConnClient manages a Feishu WebSocket long connection. +// LongConnClient manages a Feishu/Lark WebSocket long connection. type LongConnClient struct { + region Region appID string wsClient *larkws.Client } -// NewLongConnClient creates a Feishu long connection client. -// When a text message arrives, it converts it to IncomingMessage and calls handler. -func NewLongConnClient(appID, appSecret string, handler MessageHandler) *LongConnClient { +// NewLongConnClient creates a long connection client on the given region's cloud. +// When a message arrives, it converts it to IncomingMessage and calls handler. +func NewLongConnClient(region Region, appID, appSecret string, handler MessageHandler) *LongConnClient { // Long connection mode does not require verificationToken or encryptKey; // those are only used for webhook signature verification and decryption. eventHandler := dispatcher.NewEventDispatcher("", ""). OnP2MessageReceiveV1(func(ctx context.Context, event *larkim.P2MessageReceiveV1) error { - msg := convertEvent(event) + msg := convertEvent(region, event) if msg == nil { return nil } return handler(ctx, msg) }) - sdkLogger := &feishuLoggerAdapter{appID: appID} + sdkLogger := &feishuLoggerAdapter{region: region, appID: appID} + // WithDomain points the SDK at the region's cloud. It defaults to + // open.feishu.cn, so Lark apps would otherwise fail to authenticate. wsClient := larkws.NewClient(appID, appSecret, larkws.WithEventHandler(eventHandler), larkws.WithAutoReconnect(true), larkws.WithLogger(sdkLogger), + larkws.WithDomain(region.OpenBaseURL), ) - return &LongConnClient{appID: appID, wsClient: wsClient} + return &LongConnClient{region: region, appID: appID, wsClient: wsClient} } // Start begins the WebSocket long connection. It blocks until ctx is cancelled. func (c *LongConnClient) Start(ctx context.Context) error { - logger.Infof(ctx, "[IM] Feishu WebSocket connecting (app_id=%s)...", c.appID) + logger.Infof(ctx, "[IM] %s WebSocket connecting (app_id=%s)...", c.region.Label, c.appID) return c.wsClient.Start(ctx) } // Close tears down the WebSocket long connection. // -// This is the only reliable way to stop a Feishu long connection: the SDK's +// This is the only reliable way to stop a long connection: the SDK's // Start blocks on a bare select{} and neither Start, pingLoop, nor // receiveMessageLoop observe the passed context, so cancelling ctx alone leaves // the underlying socket alive and the SDK auto-reconnecting. Client.Close() @@ -66,48 +70,50 @@ func (c *LongConnClient) Close() { c.wsClient.Close() } -// feishuLoggerAdapter bridges the Feishu SDK logger to our unified logger, +// feishuLoggerAdapter bridges the Feishu/Lark SDK logger to our unified logger, // replacing raw SDK connection messages with a consistent format. type feishuLoggerAdapter struct { - appID string + region Region + appID string } func (l *feishuLoggerAdapter) Debug(ctx context.Context, args ...interface{}) { - logger.Debugf(ctx, "[Feishu] %s", fmt.Sprint(args...)) + logger.Debugf(ctx, "[%s] %s", l.region.Label, fmt.Sprint(args...)) } func (l *feishuLoggerAdapter) Info(ctx context.Context, args ...interface{}) { msg := fmt.Sprint(args...) if strings.HasPrefix(msg, "connected to ") { - logger.Infof(ctx, "[IM] Feishu WebSocket connected successfully (app_id=%s)", l.appID) + logger.Infof(ctx, "[IM] %s WebSocket connected successfully (app_id=%s)", l.region.Label, l.appID) return } - logger.Infof(ctx, "[Feishu] %s", msg) + logger.Infof(ctx, "[%s] %s", l.region.Label, msg) } func (l *feishuLoggerAdapter) Warn(ctx context.Context, args ...interface{}) { - logger.Warnf(ctx, "[Feishu] %s", fmt.Sprint(args...)) + logger.Warnf(ctx, "[%s] %s", l.region.Label, fmt.Sprint(args...)) } func (l *feishuLoggerAdapter) Error(ctx context.Context, args ...interface{}) { - logger.Errorf(ctx, "[Feishu] %s", fmt.Sprint(args...)) + logger.Errorf(ctx, "[%s] %s", l.region.Label, fmt.Sprint(args...)) } -// convertEvent converts a Feishu SDK event to a unified IncomingMessage. -// Supports text and file messages. Returns nil for unsupported types. -func convertEvent(event *larkim.P2MessageReceiveV1) *im.IncomingMessage { +// convertEvent converts a Feishu/Lark SDK event to a unified IncomingMessage. +// Supports text, file, image and post messages. Returns nil for other types. +func convertEvent(region Region, event *larkim.P2MessageReceiveV1) *im.IncomingMessage { if event == nil || event.Event == nil || event.Event.Message == nil { - logger.Warnf(context.Background(), "[Feishu][RX] event dropped: nil event/event/message") + logger.Warnf(context.Background(), "[%s][RX] event dropped: nil event/event/message", region.Label) return nil } msg := event.Event.Message - // Debug: log every raw receive event so we can see exactly what Feishu + // Debug: log every raw receive event so we can see exactly what the platform // delivers (or doesn't). This is the single chokepoint for all message // types — adding it here catches text/file/image/post uniformly. logger.Infof(context.Background(), - "[Feishu][RX] msg_type=%q chat_type=%q chat_id=%q msg_id=%q root_id=%q parent_id=%q thread_id=%q content=%q", + "[%s][RX] msg_type=%q chat_type=%q chat_id=%q msg_id=%q root_id=%q parent_id=%q thread_id=%q content=%q", + region.Label, ptrStr(msg.MessageType), ptrStr(msg.ChatType), ptrStr(msg.ChatId), ptrStr(msg.MessageId), ptrStr(msg.RootId), ptrStr(msg.ParentId), ptrStr(msg.ThreadId), ptrStr(msg.Content), @@ -143,20 +149,23 @@ func convertEvent(event *larkim.P2MessageReceiveV1) *im.IncomingMessage { switch msgType { case "text": - return convertTextEvent(msg, openID, chatID, chatType, messageID) + return convertTextEvent(region, msg, openID, chatID, chatType, messageID) case "file": - return convertFileEvent(msg, openID, chatID, chatType, messageID) + return convertFileEvent(region, msg, openID, chatID, chatType, messageID) case "image": - return convertImageEvent(msg, openID, chatID, chatType, messageID) + return convertImageEvent(region, msg, openID, chatID, chatType, messageID) case "post": - return convertPostEvent(msg, openID, chatID, chatType, messageID) + return convertPostEvent(region, msg, openID, chatID, chatType, messageID) default: return nil } } // convertTextEvent handles text message type. -func convertTextEvent(msg *larkim.EventMessage, openID, chatID string, chatType im.ChatType, messageID string) *im.IncomingMessage { +func convertTextEvent( + region Region, msg *larkim.EventMessage, + openID, chatID string, chatType im.ChatType, messageID string, +) *im.IncomingMessage { var textContent struct { Text string `json:"text"` } @@ -180,7 +189,7 @@ func convertTextEvent(msg *larkim.EventMessage, openID, chatID string, chatType } return &im.IncomingMessage{ - Platform: im.PlatformFeishu, + Platform: region.Platform, MessageType: im.MessageTypeText, UserID: openID, ChatID: chatID, @@ -191,7 +200,10 @@ func convertTextEvent(msg *larkim.EventMessage, openID, chatID string, chatType } // convertFileEvent handles file message type. -func convertFileEvent(msg *larkim.EventMessage, openID, chatID string, chatType im.ChatType, messageID string) *im.IncomingMessage { +func convertFileEvent( + region Region, msg *larkim.EventMessage, + openID, chatID string, chatType im.ChatType, messageID string, +) *im.IncomingMessage { if msg.Content == nil { return nil } @@ -207,7 +219,7 @@ func convertFileEvent(msg *larkim.EventMessage, openID, chatID string, chatType } return &im.IncomingMessage{ - Platform: im.PlatformFeishu, + Platform: region.Platform, MessageType: im.MessageTypeFile, UserID: openID, ChatID: chatID, @@ -220,7 +232,10 @@ func convertFileEvent(msg *larkim.EventMessage, openID, chatID string, chatType // convertImageEvent handles image message type. // Downloads via GetMessageResource API with type=image. -func convertImageEvent(msg *larkim.EventMessage, openID, chatID string, chatType im.ChatType, messageID string) *im.IncomingMessage { +func convertImageEvent( + region Region, msg *larkim.EventMessage, + openID, chatID string, chatType im.ChatType, messageID string, +) *im.IncomingMessage { if msg.Content == nil { return nil } @@ -235,7 +250,7 @@ func convertImageEvent(msg *larkim.EventMessage, openID, chatID string, chatType } return &im.IncomingMessage{ - Platform: im.PlatformFeishu, + Platform: region.Platform, MessageType: im.MessageTypeImage, UserID: openID, ChatID: chatID, @@ -248,7 +263,10 @@ func convertImageEvent(msg *larkim.EventMessage, openID, chatID string, chatType // convertPostEvent handles rich-text (post) message type. // Extracts all plain text content and treats it as a text query for QA. -func convertPostEvent(msg *larkim.EventMessage, openID, chatID string, chatType im.ChatType, messageID string) *im.IncomingMessage { +func convertPostEvent( + region Region, msg *larkim.EventMessage, + openID, chatID string, chatType im.ChatType, messageID string, +) *im.IncomingMessage { if msg.Content == nil { return nil } @@ -307,7 +325,7 @@ func convertPostEvent(msg *larkim.EventMessage, openID, chatID string, chatType } return &im.IncomingMessage{ - Platform: im.PlatformFeishu, + Platform: region.Platform, MessageType: im.MessageTypeText, UserID: openID, ChatID: chatID, diff --git a/internal/im/feishu/region.go b/internal/im/feishu/region.go new file mode 100644 index 000000000..1ea04caef --- /dev/null +++ b/internal/im/feishu/region.go @@ -0,0 +1,49 @@ +package feishu + +import "github.com/Tencent/WeKnora/internal/im" + +// Open Platform API origins. Feishu and Lark are the same product deployed on +// two isolated clouds; the API surface is identical, only the host differs. +const ( + feishuOpenBaseURL = "https://open.feishu.cn" + larkOpenBaseURL = "https://open.larksuite.com" +) + +// Region selects which cloud an adapter talks to. Apps, tenants, tokens and +// resource keys (image_key, file_key, card_id) are scoped to a single cloud and +// are never valid on the other, so a Region is fixed per channel at creation. +type Region struct { + // Platform is the im.Platform reported on messages parsed by this adapter. + Platform im.Platform + // OpenBaseURL is the Open Platform API origin, without a trailing slash. + OpenBaseURL string + // Label prefixes log lines so operators can tell the clouds apart. + Label string + // ThinkingText is the placeholder shown in a streaming card before the + // first answer chunk arrives. + ThinkingText string + // ImageFallbackLabel labels the plain link an image degrades to when it + // cannot be uploaded to the platform. + ImageFallbackLabel string +} + +var ( + // RegionFeishu is the Chinese mainland cloud (飞书). Its users are addressed + // in Chinese, matching the console and app language. + RegionFeishu = Region{ + Platform: im.PlatformFeishu, + OpenBaseURL: feishuOpenBaseURL, + Label: "Feishu", + ThinkingText: "正在思考...", + ImageFallbackLabel: "图片", + } + + // RegionLark is the international cloud (Lark), addressed in English. + RegionLark = Region{ + Platform: im.PlatformLark, + OpenBaseURL: larkOpenBaseURL, + Label: "Lark", + ThinkingText: "Thinking...", + ImageFallbackLabel: "Image", + } +) diff --git a/internal/im/feishu/region_test.go b/internal/im/feishu/region_test.go new file mode 100644 index 000000000..8671927e6 --- /dev/null +++ b/internal/im/feishu/region_test.go @@ -0,0 +1,128 @@ +package feishu + +import ( + "encoding/json" + "strings" + "testing" + + "github.com/Tencent/WeKnora/internal/im" +) + +// The two regions must stay distinct in every dimension that reaches the +// network or the session store: sending a Lark request to open.feishu.cn fails +// authentication, and reporting the wrong platform merges the two clouds' +// sessions together. +func TestRegions_AreDistinct(t *testing.T) { + if RegionFeishu.OpenBaseURL == RegionLark.OpenBaseURL { + t.Errorf("regions share OpenBaseURL %q", RegionFeishu.OpenBaseURL) + } + if RegionFeishu.Platform == RegionLark.Platform { + t.Errorf("regions share Platform %q", RegionFeishu.Platform) + } + if RegionFeishu.Platform != im.PlatformFeishu { + t.Errorf("RegionFeishu.Platform = %q, want %q", RegionFeishu.Platform, im.PlatformFeishu) + } + if RegionLark.Platform != im.PlatformLark { + t.Errorf("RegionLark.Platform = %q, want %q", RegionLark.Platform, im.PlatformLark) + } +} + +func TestRegions_BaseURLHosts(t *testing.T) { + cases := []struct { + region Region + want string + }{ + {RegionFeishu, "https://open.feishu.cn"}, + {RegionLark, "https://open.larksuite.com"}, + } + for _, c := range cases { + if c.region.OpenBaseURL != c.want { + t.Errorf("%s OpenBaseURL = %q, want %q", c.region.Label, c.region.OpenBaseURL, c.want) + } + if strings.HasSuffix(c.region.OpenBaseURL, "/") { + t.Errorf("%s OpenBaseURL has a trailing slash: %q", c.region.Label, c.region.OpenBaseURL) + } + } +} + +// Every API call must land on the region's own cloud. +func TestAdapterAPI_UsesRegionHost(t *testing.T) { + cases := []struct { + region Region + wantPrefix string + }{ + {RegionFeishu, "https://open.feishu.cn/open-apis/"}, + {RegionLark, "https://open.larksuite.com/open-apis/"}, + } + for _, c := range cases { + a := NewAdapter(c.region, "cli_app", "secret", "", "") + + got := a.api("/open-apis/im/v1/messages/%s/reply", "om_1") + want := c.wantPrefix + "im/v1/messages/om_1/reply" + if got != want { + t.Errorf("%s api() = %q, want %q", c.region.Label, got, want) + } + + // A path with no format verbs must survive unchanged. + if got := a.api("/open-apis/cardkit/v1/cards"); got != c.wantPrefix+"cardkit/v1/cards" { + t.Errorf("%s api() = %q, want %q", c.region.Label, got, c.wantPrefix+"cardkit/v1/cards") + } + } +} + +func TestAdapterPlatform_FollowsRegion(t *testing.T) { + if got := NewAdapter(RegionFeishu, "a", "b", "", "").Platform(); got != im.PlatformFeishu { + t.Errorf("Feishu adapter Platform() = %q, want %q", got, im.PlatformFeishu) + } + if got := NewAdapter(RegionLark, "a", "b", "", "").Platform(); got != im.PlatformLark { + t.Errorf("Lark adapter Platform() = %q, want %q", got, im.PlatformLark) + } +} + +// The streaming card placeholder follows the region so Lark users are not shown +// Chinese copy. +func TestBuildStreamingCardJSON_PlaceholderFollowsRegion(t *testing.T) { + for _, region := range []Region{RegionFeishu, RegionLark} { + raw := buildStreamingCardJSON(region) + + var card struct { + Config struct { + StreamingMode bool `json:"streaming_mode"` + Summary struct { + Content string `json:"content"` + } `json:"summary"` + } `json:"config"` + Body struct { + Elements []struct { + Content string `json:"content"` + ElementID string `json:"element_id"` + } `json:"elements"` + } `json:"body"` + } + if err := json.Unmarshal([]byte(raw), &card); err != nil { + t.Fatalf("%s card is not valid JSON: %v", region.Label, err) + } + + if !card.Config.StreamingMode { + t.Errorf("%s card has streaming_mode disabled", region.Label) + } + if card.Config.Summary.Content != region.ThinkingText { + t.Errorf("%s summary = %q, want %q", region.Label, card.Config.Summary.Content, region.ThinkingText) + } + if len(card.Body.Elements) != 1 { + t.Fatalf("%s card has %d elements, want 1", region.Label, len(card.Body.Elements)) + } + el := card.Body.Elements[0] + if el.ElementID != streamingElementID { + t.Errorf("%s element_id = %q, want %q", region.Label, el.ElementID, streamingElementID) + } + if !strings.Contains(el.Content, region.ThinkingText) { + t.Errorf("%s element content %q does not contain %q", region.Label, el.Content, region.ThinkingText) + } + } + + // Guard the point of the whole exercise: the copy actually differs. + if RegionFeishu.ThinkingText == RegionLark.ThinkingText { + t.Error("Feishu and Lark share ThinkingText; Lark users would see Chinese copy") + } +} diff --git a/internal/im/feishu/send_e2e_test.go b/internal/im/feishu/send_e2e_test.go new file mode 100644 index 000000000..4322519be --- /dev/null +++ b/internal/im/feishu/send_e2e_test.go @@ -0,0 +1,184 @@ +package feishu + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + + "github.com/Tencent/WeKnora/internal/im" +) + +// testRegion returns a Lark-shaped region pointed at a fake Open Platform, so +// the whole send path can be driven without touching the real cloud. Region +// carries the host as a field precisely so this is possible. +func testRegion(baseURL string) Region { + r := RegionLark + r.OpenBaseURL = baseURL + return r +} + +// fakeOpenPlatform records what the adapter asked for, and answers as the real +// Open Platform would. +type fakeOpenPlatform struct { + mu sync.Mutex + // paths records every request path in order. + paths []string + // replyAuth is the Authorization header seen on the reply call. + replyAuth string + // replyBody is the decoded JSON body of the reply call. + replyBody map[string]any + // sendBody is the decoded JSON body of the fallback send call. + sendBody map[string]any + // sendQuery is the raw query string of the fallback send call. + sendQuery string + // replyCode is returned from the reply endpoint (0 = success). + replyCode int +} + +func (f *fakeOpenPlatform) handler() http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + f.mu.Lock() + f.paths = append(f.paths, r.URL.Path) + f.mu.Unlock() + + switch { + case r.URL.Path == "/open-apis/auth/v3/tenant_access_token/internal": + _ = json.NewEncoder(w).Encode(map[string]any{ + "code": 0, "tenant_access_token": "t-lark-xyz", "expire": 7200, + }) + + case strings.HasSuffix(r.URL.Path, "/reply"): + f.mu.Lock() + f.replyAuth = r.Header.Get("Authorization") + _ = json.NewDecoder(r.Body).Decode(&f.replyBody) + code := f.replyCode + f.mu.Unlock() + _ = json.NewEncoder(w).Encode(map[string]any{"code": code, "msg": "mock"}) + + case r.URL.Path == "/open-apis/im/v1/messages": + f.mu.Lock() + f.sendQuery = r.URL.RawQuery + _ = json.NewDecoder(r.Body).Decode(&f.sendBody) + f.mu.Unlock() + _ = json.NewEncoder(w).Encode(map[string]any{"code": 0, "msg": "ok"}) + + default: + http.NotFound(w, r) + } + }) +} + +func (f *fakeOpenPlatform) sawPath(want string) bool { + f.mu.Lock() + defer f.mu.Unlock() + for _, p := range f.paths { + if p == want { + return true + } + } + return false +} + +// SendReply must fetch a token from the region's cloud and reply under the +// original message, carrying that token as a bearer credential. +func TestSendReply_EndToEnd_UsesRegionCloud(t *testing.T) { + fake := &fakeOpenPlatform{} + srv := httptest.NewServer(fake.handler()) + defer srv.Close() + + a := NewAdapter(testRegion(srv.URL), "cli_app", "secret", "", "") + incoming := &im.IncomingMessage{ + Platform: im.PlatformLark, + UserID: "ou_user1", + ChatType: im.ChatTypeDirect, + MessageID: "om_msg1", + } + + if err := a.SendReply(context.Background(), incoming, &im.ReplyMessage{Content: "hello lark"}); err != nil { + t.Fatalf("SendReply: %v", err) + } + + if !fake.sawPath("/open-apis/auth/v3/tenant_access_token/internal") { + t.Errorf("no token fetch; paths = %v", fake.paths) + } + if !fake.sawPath("/open-apis/im/v1/messages/om_msg1/reply") { + t.Errorf("reply did not target the message; paths = %v", fake.paths) + } + if fake.replyAuth != "Bearer t-lark-xyz" { + t.Errorf("reply Authorization = %q, want %q", fake.replyAuth, "Bearer t-lark-xyz") + } + if got := fake.replyBody["msg_type"]; got != "text" { + t.Errorf("msg_type = %v, want text", got) + } + // content is a JSON-encoded string, per the Open Platform contract. + var content struct { + Text string `json:"text"` + } + raw, _ := fake.replyBody["content"].(string) + if err := json.Unmarshal([]byte(raw), &content); err != nil { + t.Fatalf("content is not a JSON string: %q", raw) + } + if content.Text != "hello lark" { + t.Errorf("text = %q, want %q", content.Text, "hello lark") + } +} + +// A group that rejects reply-in-thread (230071) must still receive the answer +// via the plain send-message API, addressed to the chat. +func TestSendReply_EndToEnd_FallsBackToSendAPI(t *testing.T) { + fake := &fakeOpenPlatform{replyCode: 230071} + srv := httptest.NewServer(fake.handler()) + defer srv.Close() + + a := NewAdapter(testRegion(srv.URL), "cli_app", "secret", "", "") + incoming := &im.IncomingMessage{ + Platform: im.PlatformLark, + UserID: "ou_user1", + ChatID: "oc_chat1", + ChatType: im.ChatTypeGroup, + MessageID: "om_msg1", + } + + if err := a.SendReply(context.Background(), incoming, &im.ReplyMessage{Content: "grouped"}); err != nil { + t.Fatalf("SendReply: %v", err) + } + + if !fake.sawPath("/open-apis/im/v1/messages") { + t.Fatalf("no fallback to send-message API; paths = %v", fake.paths) + } + if fake.sendQuery != "receive_id_type=chat_id" { + t.Errorf("send query = %q, want receive_id_type=chat_id", fake.sendQuery) + } + if got := fake.sendBody["receive_id"]; got != "oc_chat1" { + t.Errorf("receive_id = %v, want oc_chat1", got) + } +} + +// A non-fallback-eligible error must surface, not be silently swallowed. +func TestSendReply_EndToEnd_HardErrorSurfaces(t *testing.T) { + fake := &fakeOpenPlatform{replyCode: 99991663} // app ticket invalid — not retryable + srv := httptest.NewServer(fake.handler()) + defer srv.Close() + + a := NewAdapter(testRegion(srv.URL), "cli_app", "secret", "", "") + incoming := &im.IncomingMessage{ + Platform: im.PlatformLark, UserID: "ou_user1", + ChatType: im.ChatTypeDirect, MessageID: "om_msg1", + } + + err := a.SendReply(context.Background(), incoming, &im.ReplyMessage{Content: "x"}) + if err == nil { + t.Fatal("SendReply returned nil for a non-retryable API error") + } + if !strings.Contains(err.Error(), "99991663") { + t.Errorf("error %q does not mention the API code", err) + } + // The plain send API must not be tried for a hard error. + if fake.sawPath("/open-apis/im/v1/messages") { + t.Errorf("fell back to send-message API on a non-retryable error; paths = %v", fake.paths) + } +} diff --git a/internal/im/think.go b/internal/im/think.go index 0ecd7e859..af889184b 100644 --- a/internal/im/think.go +++ b/internal/im/think.go @@ -187,7 +187,13 @@ type ThinkBlockStyle struct { } // MarkdownThinkStyle renders think blocks as markdown blockquotes. -// Used by DingTalk and Feishu. +// Used by DingTalk and Feishu/Lark. +// +// Known limitation: the headers below are Chinese for every platform, including +// ones whose users are typically not Chinese speakers (Lark, Slack, Telegram, +// Mattermost). Localizing them means threading a locale into +// FormatIMDisplayContent, which is called from the shared streaming path with no +// platform context — a cross-platform change rather than a per-platform patch. var MarkdownThinkStyle = ThinkBlockStyle{ ThinkingHeader: "> 💭 **思考中...**\n", ThoughtHeader: "> 💭 **思考过程**\n", diff --git a/internal/im/types.go b/internal/im/types.go index 58bced5af..17f79ad65 100644 --- a/internal/im/types.go +++ b/internal/im/types.go @@ -170,9 +170,11 @@ func (ch *IMChannel) computeBotIdentity() string { return "wecom:wh:" + corpID + ":" + agentID } } - case "feishu": + // Feishu and Lark app_ids live in separate clouds and never collide, so the + // platform prefix keeps the same app_id on both from looking like one bot. + case "feishu", "lark": if appID := str("app_id"); appID != "" { - return "feishu:" + appID + return ch.Platform + ":" + appID } case "telegram": if botToken := str("bot_token"); botToken != "" { diff --git a/internal/im/types_test.go b/internal/im/types_test.go index 2b3dd0c4b..b3301a196 100644 --- a/internal/im/types_test.go +++ b/internal/im/types_test.go @@ -130,6 +130,36 @@ func TestSessionModeConstants(t *testing.T) { } } +// BotIdentity carries a unique index, so Feishu and Lark channels must never +// derive the same identity — otherwise a Lark bot would be rejected as a +// duplicate of an unrelated Feishu bot that happens to share an app_id. +func TestComputeBotIdentity_FeishuAndLarkAreDistinct(t *testing.T) { + const creds = `{"app_id":"cli_a1b2c3","app_secret":"s"}` + + feishu := &IMChannel{Platform: "feishu", Credentials: []byte(creds)} + lark := &IMChannel{Platform: "lark", Credentials: []byte(creds)} + + feishuID := feishu.computeBotIdentity() + larkID := lark.computeBotIdentity() + + if feishuID != "feishu:cli_a1b2c3" { + t.Errorf("feishu identity = %q, want %q", feishuID, "feishu:cli_a1b2c3") + } + if larkID != "lark:cli_a1b2c3" { + t.Errorf("lark identity = %q, want %q", larkID, "lark:cli_a1b2c3") + } + if feishuID == larkID { + t.Errorf("feishu and lark share identity %q", feishuID) + } +} + +func TestComputeBotIdentity_LarkWithoutAppID(t *testing.T) { + ch := &IMChannel{Platform: "lark", Credentials: []byte(`{"app_secret":"s"}`)} + if got := ch.computeBotIdentity(); got != "" { + t.Errorf("identity = %q, want empty when app_id is missing", got) + } +} + func TestChannelSessionThreadIDField(t *testing.T) { cs := ChannelSession{ Platform: "slack", diff --git a/internal/types/datasource.go b/internal/types/datasource.go index 40898cdb6..defc99c6f 100644 --- a/internal/types/datasource.go +++ b/internal/types/datasource.go @@ -14,7 +14,10 @@ import ( // Data source types and constants const ( // Connector types - ConnectorTypeFeishu = "feishu" + ConnectorTypeFeishu = "feishu" + // ConnectorTypeLark is Feishu's international edition (open.larksuite.com). + // It shares the Feishu connector; only the API host and tenant differ. + ConnectorTypeLark = "lark" ConnectorTypeNotion = "notion" ConnectorTypeConfluence = "confluence" ConnectorTypeYuque = "yuque"