mirror of
https://github.com/Tencent/WeKnora.git
synced 2026-10-29 18:49:20 +08:00
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:
committed by
lyingbug
co-authored by
Claude Opus 4.8
parent
3edd9c2c84
commit
9afb8b7b68
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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` 构造适配器与长连接客户端 |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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
@@ -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」类型。
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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 |
@@ -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 |
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 피드에서 글 동기화",
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -69,7 +69,7 @@ func TestImageCacheKey_StripsQuery(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestResolveMarkdownImages_NoImageUnchanged(t *testing.T) {
|
||||
a := &Adapter{}
|
||||
a := &Adapter{region: RegionFeishu}
|
||||
in := "hello **world** [link](https://example.com)"
|
||||
if got := a.resolveMarkdownImages(context.Background(), "tok", in); got != in {
|
||||
t.Errorf("content without image was modified: %q", got)
|
||||
@@ -77,7 +77,7 @@ func TestResolveMarkdownImages_NoImageUnchanged(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestResolveMarkdownImages_FallbackToLinkOnFailure(t *testing.T) {
|
||||
a := &Adapter{}
|
||||
a := &Adapter{region: RegionFeishu}
|
||||
// A direct-IP loopback URL fails SSRF validation before any network call,
|
||||
// so the image must degrade to a plain markdown link (never left as ![]()).
|
||||
in := "see  here"
|
||||
@@ -89,3 +89,16 @@ func TestResolveMarkdownImages_FallbackToLinkOnFailure(t *testing.T) {
|
||||
t.Errorf("expected link fallback with alt text, got: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// An image with no alt text falls back to the region's own label, so Lark users
|
||||
// do not get a Chinese link label.
|
||||
func TestResolveMarkdownImages_FallbackLabelFollowsRegion(t *testing.T) {
|
||||
for _, region := range []Region{RegionFeishu, RegionLark} {
|
||||
a := &Adapter{region: region}
|
||||
got := a.resolveMarkdownImages(context.Background(), "tok", "")
|
||||
want := "[" + region.ImageFallbackLabel + "](http://127.0.0.1/x.png)"
|
||||
if got != want {
|
||||
t.Errorf("%s fallback = %q, want %q", region.Label, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
)
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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 != "" {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user