feat(im,datasource): support Lark, Feishu's international edition

Lark and Feishu are the same product deployed on two isolated clouds
(open.larksuite.com and open.feishu.cn). WeKnora touches both from two
places — the IM bot and the Wiki data-source connector — and this brings
Lark to parity across both. Neither API surface differs, so rather than
duplicate either implementation, each is parametrized by a Region and
registered twice.

IM (internal/im/feishu):
  Every API call routes through a.api(), which prefixes the region's
  host, and the WebSocket client passes larkws.WithDomain — the SDK
  defaults to open.feishu.cn, so Lark long connections would otherwise
  fail to authenticate.

Data source (internal/datasource/connector/feishu):
  Lark was half-supported: the client honoured a base_url override and a
  LarkBaseURL constant existed, but the connector registered only as
  "feishu" and the UI never exposed base_url, so Lark wikis could not be
  configured at all. Lark is now its own connector type. base_url is kept
  as an explicit override so data sources that pointed a "feishu"
  connector at open.larksuite.com keep working.

Bugs found and fixed along the way:

- Wiki resource links were hardcoded to https://feishu.cn/wiki/{token} in
  four places, so a Lark wiki linked into the wrong cloud entirely.
- Bot identity is now platform-prefixed (feishu:cli_x vs lark:cli_x).
  BotIdentity carries a unique index, so without this a Lark channel
  would be rejected as a duplicate of an unrelated Feishu bot sharing an
  app_id.
- The card image cache is now keyed by app_id. image_keys are issued per
  app on one cloud; a Feishu key handed to a Lark app fails the card
  update with code=200570.
- The datasource doc claimed Lark "adapts automatically" — it could not,
  since the UI had no way to reach base_url.

Permissions are where the clouds genuinely diverge. Identical APIs do not
mean identical permission catalogues: aily:file:read, aily:file:write and
corehr:file:download exist on Feishu but not on Lark, so pasting Feishu's
scope JSON into Lark fails to import. That JSON is also over-broad (it
covers products WeKnora never calls) and simultaneously incomplete (it
omits the drive:*/docx:* scopes the connector needs). Lark therefore gets
its own list, derived from the 17 endpoints the code actually calls and
checked identifier-by-identifier against Lark's own API reference, plus a
full Feishu→Lark mapping table marking each scope verified, unverified,
or absent. Both features use tenant_access_token only — no user_access_
token exists anywhere in the repo — so the user dimension stays empty.

Known limitation: Region.ThinkingText makes Lark's initial card
placeholder English, but once streaming starts the think-block headers
come from the shared MarkdownThinkStyle and are Chinese on every platform
(Lark, Slack, Telegram, Mattermost alike). Fixing that means threading a
locale through FormatIMDisplayContent, which has no platform context — a
cross-platform change, not a Lark one. Documented as such.

The channel-creation 400 message is now derived from validIMPlatforms so
the list cannot drift as platforms are added.

Tests: region invariants and per-region URL construction on both sides,
the Feishu/Lark bot-identity split, platform-to-channel mapping, base_url
override precedence, an end-to-end SendReply against a fake Open Platform
(token fetch, reply, thread-unsupported fallback, hard-error surfacing),
and a ListResources drive asserting wiki links never leak the other
cloud's host.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
qilifan
2026-07-17 11:05:54 +08:00
committed by lyingbug
co-authored by Claude Opus 4.8
parent 3edd9c2c84
commit 9afb8b7b68
41 changed files with 1187 additions and 160 deletions
+1 -1
View File
@@ -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 |
+1 -1
View File
@@ -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 |
+1 -1
View File
@@ -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 |
+1 -1
View File
@@ -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 |
+135 -8
View File
@@ -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 |
|---|---|---|
| 开放平台 | <https://open.feishu.cn/> | <https://open.larksuite.com/> |
| 添加渠道时的**平台**选项 | 「飞书」 | 「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`<br>`PUT /cardkit/v1/cards/{id}/elements/{eid}/content`<br>`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` 构造适配器与长连接客户端 |
---
+17 -2
View File
@@ -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 接口一致,与飞书共用同一套适配器。接入步骤基本相同,但开放平台
与**权限清单**不同:
- 飞书:<https://open.feishu.cn/>
- Lark:<https://open.larksuite.com/>
> **权限不可套用飞书的清单**:飞书清单混入了数据源连接器(Wiki 同步)用的权限,其中部分在
> Lark 并不存在,整份导入会失败。Lark 只需 IM 相关的 6 项,见
> [IM集成开发文档 — Lark 权限配置](../../IM集成开发文档.md#lark-权限配置)。
> 两朵云的应用互不通用,凭证只在创建它的那朵云上有效。
### Slack 接入
- **Socket Mode**(推荐)— 无需公网域名
+36 -1
View File
@@ -82,7 +82,42 @@ WeKnora 的数据源导入模块支持从外部平台(飞书、企业微信、
- **成功**:显示已创建/更新/跳过/失败的文档数量
- **失败**:显示错误信息,常见原因为权限不足或 Token 过期
> **注意**:飞书国际版(Lark)同样支持,在创建数据源时会自动适配 `https://open.larksuite.com` 的 API 地址。
---
### Lark 知识库接入
Lark 是飞书的国际版。Wiki / docx / drive 接口与飞书一致,**共用同一套连接器代码**,接入步骤
与[飞书知识库接入](#飞书知识库接入)相同,差别只有三处:
| | 飞书 | Lark |
|---|---|---|
| 开放平台 | <https://open.feishu.cn/> | <https://open.larksuite.com/> |
| 连接器类型 | 「飞书」 | 「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」类型。
---
+2 -1
View File
@@ -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';
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 48 48"><g fill="#3370ff" fill-rule="evenodd" clip-rule="evenodd"><path d="M41.0716 5.99409L3.31071 16.5187L12.3856 25.8126L20.7998 25.9594L30.4827 16.5187C30.2266 15.9943 30.0985 15.5552 30.0985 15.2013C30.0985 14.4074 30.4104 13.7786 30.8947 13.333C31.7241 12.57 32.7222 12.4558 33.8889 12.9905L41.0716 5.99409Z"/><path d="M42.1021 6.72842L31.5775 44.4893L22.2836 35.4144L22.1367 27.0002L31.5115 17.4816C32.0195 17.8454 32.5743 18.0105 33.1759 17.9769C34.0784 17.9264 34.6614 17.3813 34.9349 17.0602C35.2083 16.7392 35.5293 16.2051 35.5025 15.4113C35.4847 14.8821 35.3109 14.3941 34.9812 13.9472L42.1021 6.72842Z"/></g></svg>

After

Width:  |  Height:  |  Size: 705 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 48 48"><g fill="#3370ff" fill-rule="evenodd" clip-rule="evenodd"><path d="M41.0716 5.99409L3.31071 16.5187L12.3856 25.8126L20.7998 25.9594L30.4827 16.5187C30.2266 15.9943 30.0985 15.5552 30.0985 15.2013C30.0985 14.4074 30.4104 13.7786 30.8947 13.333C31.7241 12.57 32.7222 12.4558 33.8889 12.9905L41.0716 5.99409Z"/><path d="M42.1021 6.72842L31.5775 44.4893L22.2836 35.4144L22.1367 27.0002L31.5115 17.4816C32.0195 17.8454 32.5743 18.0105 33.1759 17.9769C34.0784 17.9264 34.6614 17.3813 34.9349 17.0602C35.2083 16.7392 35.5293 16.2051 35.5025 15.4113C35.4847 14.8821 35.3109 14.3941 34.9812 13.9472L42.1021 6.72842Z"/></g></svg>

After

Width:  |  Height:  |  Size: 705 B

+16 -5
View File
@@ -295,11 +295,11 @@
</template>
</template>
<!-- Feishu credentials -->
<template v-if="formData.platform === 'feishu'">
<!-- Feishu / Lark credentials — same fields, different open platform -->
<template v-if="formData.platform === 'feishu' || formData.platform === 'lark'">
<div class="platform-link-hint">
<a href="https://open.feishu.cn/" target="_blank" rel="noopener noreferrer" class="doc-link">
{{ $t('agentEditor.im.feishuConsole') }}
<a :href="openPlatformConsole.url" target="_blank" rel="noopener noreferrer" class="doc-link">
{{ $t(openPlatformConsole.labelKey) }}
<t-icon name="link" class="link-icon" />
</a>
<span class="hint-text">{{ $t('agentEditor.im.consoleTip') }}</span>
@@ -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<string, string> = {
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(
+2
View File
@@ -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<string, string> = {
wecom: wecomLogo,
feishu: feishuLogo,
lark: larkLogo,
slack: slackLogo,
telegram: telegramLogo,
dingtalk: dingtalkLogo,
+5 -1
View File
@@ -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',
+4
View File
@@ -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 피드에서 글 동기화",
+4
View File
@@ -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',
+4
View File
@@ -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 订阅源中的文章",
@@ -368,6 +368,26 @@ const connectorDefs = computed<ConnectorDef[]>(() => [
{ 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,
@@ -17,6 +17,8 @@ function fallbackText(type: string) {
switch (type) {
case 'feishu':
return 'F'
case 'lark':
return 'L'
case 'notion':
return 'N'
case 'yuque':
@@ -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<string, string> = {
feishu: feishuIcon,
lark: larkIcon,
notion: notionIcon,
yuque: yuqueIcon,
rss: rssIcon,
+8 -2
View File
@@ -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))
}
+8
View File
@@ -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",
@@ -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 {
@@ -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)
}
@@ -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",
},
}
}
@@ -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
}
@@ -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)
}
})
}
}
+16 -3
View File
@@ -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
}
+33
View File
@@ -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)
}
}
}
+6 -3
View File
@@ -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,
+34
View File
@@ -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)
}
}
}
+56 -38
View File
@@ -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)
+15 -2
View File
@@ -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 ![diagram](http://127.0.0.1/x.png) 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", "![](http://127.0.0.1/x.png)")
want := "[" + region.ImageFallbackLabel + "](http://127.0.0.1/x.png)"
if got != want {
t.Errorf("%s fallback = %q, want %q", region.Label, got, want)
}
}
}
+11 -7
View File
@@ -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)
}
}
}
+52 -34
View File
@@ -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,
+49
View File
@@ -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",
}
)
+128
View File
@@ -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")
}
}
+184
View File
@@ -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)
}
}
+7 -1
View File
@@ -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",
+4 -2
View File
@@ -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 != "" {
+30
View File
@@ -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",
+4 -1
View File
@@ -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"