fix(providers): 增强 OpenAI 兼容接口报错信息,完善 SiliconFlow 兼容性并修复 Windows 下 Codex CLI 测试
- 优化 openai_compat 的 HTTP 错误处理,支持解析结构化 JSON 错误体 - 在报错中补充 api_base、model、path、message、code 等上下文信息 - 增加 SiliconFlow 作为一等 OpenAI-compatible provider 别名 - 支持在设置 api_base 时直接使用上游原始模型名(如 Pro/zai-org/GLM-4.7) - 更新 README、README.zh.md 和 config.example.json 中的 SiliconFlow / api_base 配置示例 - 修复 Windows 下 Codex CLI mock 测试无法执行临时脚本的问题 update:主要完善了 PicoClaw 对飞书的支持,重点包括以下内容: 基于飞书官方 Go SDK 完善飞书通道能力 补全飞书消息收发相关实现 增强对飞书富文本/卡片消息的兼容处理 对齐 Python SDK 示例中的已有功能范围 增强飞书媒体消息处理能力 支持从飞书消息中递归提取所有图片、文件等附件信息 支持多图片、多附件消息的解析与处理 优化附件下载与媒体引用传递流程 接入现有图片/视觉处理链路 将飞书图片消息接入 PicoClaw 现有多模态处理流程 使图片能够随用户文本一起进入模型请求 为后续视觉模型识图能力打通基础链路 修复 Feishu/Windows amd64 相关构建与运行问题 清理 Feishu 包内重复定义与残留实现 修复 64 位 Windows 构建过程中的编译问题 修复因错误架构判断导致的飞书通道不可用问题 改进 OpenAI-compatible Provider 对 GLM/Zhipu 多模态请求的兼容性 针对 glm / zhipu 兼容端点增加图片请求格式适配 调整多模态消息序列化逻辑 对齐 glm-4.6v-flash 官方样例的请求结构 补充测试并完成回归验证 增加飞书消息解析、多附件提取、多模态序列化等测试 已通过 pkg/providers/openai_compat、pkg/channels/feishu、pkg/tools 相关测试
This commit is contained in:
parent
8654ec90d9
commit
c797d600d2
31 changed files with 3932 additions and 128 deletions
20
README.md
20
README.md
|
|
@ -1005,6 +1005,8 @@ The subagent has access to tools (message, web_search, etc.) and can communicate
|
||||||
### Model Configuration (model_list)
|
### Model Configuration (model_list)
|
||||||
|
|
||||||
> **What's New?** PicoClaw now uses a **model-centric** configuration approach. Simply specify `vendor/model` format (e.g., `zhipu/glm-4.7`) to add new providers—**zero code changes required!**
|
> **What's New?** PicoClaw now uses a **model-centric** configuration approach. Simply specify `vendor/model` format (e.g., `zhipu/glm-4.7`) to add new providers—**zero code changes required!**
|
||||||
|
>
|
||||||
|
> **OpenAI-compatible fallback:** if you already know the upstream `api_base`, you can also use the raw upstream model identifier directly (for example `Pro/zai-org/GLM-4.7`). When `api_base` is set, unknown prefixes are treated as a generic OpenAI-compatible HTTP backend instead of failing protocol detection.
|
||||||
|
|
||||||
This design also enables **multi-agent support** with flexible provider selection:
|
This design also enables **multi-agent support** with flexible provider selection:
|
||||||
|
|
||||||
|
|
@ -1036,7 +1038,7 @@ This design also enables **multi-agent support** with flexible provider selectio
|
||||||
| **Vivgrid** | `vivgrid/` | `https://api.vivgrid.com/v1` | OpenAI | [Get Key](https://vivgrid.com) |
|
| **Vivgrid** | `vivgrid/` | `https://api.vivgrid.com/v1` | OpenAI | [Get Key](https://vivgrid.com) |
|
||||||
| **Antigravity** | `antigravity/` | Google Cloud | Custom | OAuth only |
|
| **Antigravity** | `antigravity/` | Google Cloud | Custom | OAuth only |
|
||||||
| **GitHub Copilot** | `github-copilot/` | `localhost:4321` | gRPC | - |
|
| **GitHub Copilot** | `github-copilot/` | `localhost:4321` | gRPC | - |
|
||||||
|
| **SiliconFlow** | `siliconflow/` | `https://api.siliconflow.cn/v1` | OpenAI | [Get Key](https://cloud.siliconflow.cn) |
|
||||||
#### Basic Configuration
|
#### Basic Configuration
|
||||||
|
|
||||||
```json
|
```json
|
||||||
|
|
@ -1131,7 +1133,18 @@ This design also enables **multi-agent support** with flexible provider selectio
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
**LiteLLM Proxy**
|
If your upstream uses model IDs that already contain slashes, you can keep the full model string and rely on `api_base` for routing:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"model_name": "siliconflow-glm47",
|
||||||
|
"model": "Pro/zai-org/GLM-4.7",
|
||||||
|
"api_base": "https://api.siliconflow.cn/v1",
|
||||||
|
"api_key": "sk-..."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
You can also use the explicit built-in alias:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
|
|
@ -1345,7 +1358,7 @@ picoclaw agent -m "Hello"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
If a third-party backend is OpenAI-compatible but not listed above, set `api_base` and keep the provider-specific model string as-is. PicoClaw will route it through the generic HTTP-compatible path.
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
## CLI Reference
|
## CLI Reference
|
||||||
|
|
@ -1504,3 +1517,4 @@ This happens when another instance of the bot is running. Make sure only one `pi
|
||||||
| **SearXNG** | Unlimited (self-hosted) | Privacy-focused metasearch (70+ engines) |
|
| **SearXNG** | Unlimited (self-hosted) | Privacy-focused metasearch (70+ engines) |
|
||||||
| **Groq** | Free tier available | Fast inference (Llama, Mixtral) |
|
| **Groq** | Free tier available | Fast inference (Llama, Mixtral) |
|
||||||
| **Cerebras** | Free tier available | Fast inference (Llama, Qwen, etc.) |
|
| **Cerebras** | Free tier available | Fast inference (Llama, Qwen, etc.) |
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -490,6 +490,8 @@ Agent 读取 HEARTBEAT.md
|
||||||
### 模型配置 (model_list)
|
### 模型配置 (model_list)
|
||||||
|
|
||||||
> **新功能!** PicoClaw 现在采用**以模型为中心**的配置方式。只需使用 `厂商/模型` 格式(如 `zhipu/glm-4.7`)即可添加新的 provider——**无需修改任何代码!**
|
> **新功能!** PicoClaw 现在采用**以模型为中心**的配置方式。只需使用 `厂商/模型` 格式(如 `zhipu/glm-4.7`)即可添加新的 provider——**无需修改任何代码!**
|
||||||
|
>
|
||||||
|
> **OpenAI 兼容回退:** 如果你已经知道上游的 `api_base`,也可以直接填写上游原始模型名(例如 `Pro/zai-org/GLM-4.7`)。当设置了 `api_base` 时,未知前缀会自动按通用 OpenAI-compatible HTTP 后端处理,而不是因为协议识别失败而报错。
|
||||||
|
|
||||||
该设计同时支持**多 Agent 场景**,提供灵活的 Provider 选择:
|
该设计同时支持**多 Agent 场景**,提供灵活的 Provider 选择:
|
||||||
|
|
||||||
|
|
@ -513,6 +515,7 @@ Agent 读取 HEARTBEAT.md
|
||||||
| **NVIDIA** | `nvidia/` | `https://integrate.api.nvidia.com/v1` | OpenAI | [获取密钥](https://build.nvidia.com) |
|
| **NVIDIA** | `nvidia/` | `https://integrate.api.nvidia.com/v1` | OpenAI | [获取密钥](https://build.nvidia.com) |
|
||||||
| **Ollama** | `ollama/` | `http://localhost:11434/v1` | OpenAI | 本地(无需密钥) |
|
| **Ollama** | `ollama/` | `http://localhost:11434/v1` | OpenAI | 本地(无需密钥) |
|
||||||
| **OpenRouter** | `openrouter/` | `https://openrouter.ai/api/v1` | OpenAI | [获取密钥](https://openrouter.ai/keys) |
|
| **OpenRouter** | `openrouter/` | `https://openrouter.ai/api/v1` | OpenAI | [获取密钥](https://openrouter.ai/keys) |
|
||||||
|
| **SiliconFlow** | `siliconflow/` | `https://api.siliconflow.cn/v1` | OpenAI | [获取密钥](https://cloud.siliconflow.cn) |
|
||||||
| **VLLM** | `vllm/` | `http://localhost:8000/v1` | OpenAI | 本地 |
|
| **VLLM** | `vllm/` | `http://localhost:8000/v1` | OpenAI | 本地 |
|
||||||
| **Cerebras** | `cerebras/` | `https://api.cerebras.ai/v1` | OpenAI | [获取密钥](https://cerebras.ai) |
|
| **Cerebras** | `cerebras/` | `https://api.cerebras.ai/v1` | OpenAI | [获取密钥](https://cerebras.ai) |
|
||||||
| **火山引擎** | `volcengine/` | `https://ark.cn-beijing.volces.com/api/v3` | OpenAI | [获取密钥](https://console.volcengine.com) |
|
| **火山引擎** | `volcengine/` | `https://ark.cn-beijing.volces.com/api/v3` | OpenAI | [获取密钥](https://console.volcengine.com) |
|
||||||
|
|
@ -636,7 +639,7 @@ Agent 读取 HEARTBEAT.md
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
如果上游模型 ID 本身就带有 `/`,可以直接保留完整模型字符串,并通过 `api_base` 指定路由:
|
||||||
#### 从旧的 `providers` 配置迁移
|
#### 从旧的 `providers` 配置迁移
|
||||||
|
|
||||||
旧的 `providers` 配置格式**已弃用**,但为向后兼容仍支持。
|
旧的 `providers` 配置格式**已弃用**,但为向后兼容仍支持。
|
||||||
|
|
@ -661,7 +664,7 @@ Agent 读取 HEARTBEAT.md
|
||||||
```
|
```
|
||||||
|
|
||||||
**新配置(推荐):**
|
**新配置(推荐):**
|
||||||
|
如果你更喜欢显式协议前缀,也可以使用内置别名:
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"model_list": [
|
"model_list": [
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,17 @@
|
||||||
"model": "deepseek/deepseek-chat",
|
"model": "deepseek/deepseek-chat",
|
||||||
"api_key": "sk-your-deepseek-key"
|
"api_key": "sk-your-deepseek-key"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"model_name": "siliconflow-glm47",
|
||||||
|
"model": "siliconflow/Pro/zai-org/GLM-4.7",
|
||||||
|
"api_key": "sk-your-siliconflow-key"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"model_name": "siliconflow-raw-model",
|
||||||
|
"model": "Pro/zai-org/GLM-4.7",
|
||||||
|
"api_base": "https://api.siliconflow.cn/v1",
|
||||||
|
"api_key": "sk-your-siliconflow-key"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"model_name": "loadbalanced-gpt4",
|
"model_name": "loadbalanced-gpt4",
|
||||||
"model": "openai/gpt-5.2",
|
"model": "openai/gpt-5.2",
|
||||||
|
|
|
||||||
|
|
@ -36,4 +36,90 @@
|
||||||
3. 配置事件订阅和Webhook URL
|
3. 配置事件订阅和Webhook URL
|
||||||
4. 设置加密(可选,生产环境建议启用)
|
4. 设置加密(可选,生产环境建议启用)
|
||||||
5. 将 App ID、App Secret、Encrypt Key 和 Verification Token(如果启用加密) 填入配置文件中
|
5. 将 App ID、App Secret、Encrypt Key 和 Verification Token(如果启用加密) 填入配置文件中
|
||||||
6. 自定义你希望 PicoClaw react 你消息时的表情(可选, Reference URL: [Feishu Emoji List](https://open.larkoffice.com/document/server-docs/im-v1/message-reaction/emojis-introduce))
|
6. 自定义你希望 PicoClaw react 你消息时的表情(可选, Reference URL: [Feishu Emoji List](https://open.larkoffice.com/document/server-docs/im-v1/message-reaction/emojis-introduce))
|
||||||
|
|
||||||
|
## 已支持能力
|
||||||
|
|
||||||
|
当前 `picoclaw` 的飞书通道已基于官方 Go SDK 接入并支持:
|
||||||
|
|
||||||
|
- WebSocket 收消息与事件订阅
|
||||||
|
- 文本 / Markdown 卡片发送
|
||||||
|
- 占位消息发送与卡片编辑
|
||||||
|
- 消息表情反应
|
||||||
|
- 图片、文件、音频、视频上传发送
|
||||||
|
- 入站消息图片/文件资源下载
|
||||||
|
- 消息详情查询、消息列表查询、消息回复
|
||||||
|
- 卡片消息解析(标题、文本、图片键、按钮)
|
||||||
|
- 用户查询、用户列表、按邮箱/手机号查用户
|
||||||
|
- 群聊信息查询、群成员列表、群列表、建群、群发消息
|
||||||
|
- 飞书消息分享链接 token 解析与消息查找辅助
|
||||||
|
|
||||||
|
这些能力覆盖了仓库中 `feishu-skill`、`feishu-file` Python 示例里的主要已实现功能;大文件分片上传仍未实现。
|
||||||
|
|
||||||
|
## 推荐权限
|
||||||
|
|
||||||
|
为了启用上面的能力,建议在飞书开放平台为应用配置至少以下权限(实际命名以飞书后台为准):
|
||||||
|
|
||||||
|
- `im:message`
|
||||||
|
- `im:message:readonly`
|
||||||
|
- `im:message.resource`
|
||||||
|
- `im:chat`
|
||||||
|
- `im:chat:readonly`
|
||||||
|
- `contact:user.base:readonly`
|
||||||
|
- `contact:user.id:readonly`
|
||||||
|
- Drive / 云文档相关文件读写权限(用于文件上传、下载、查询、删除)
|
||||||
|
|
||||||
|
如果只需要基础聊天能力,可先只开启消息和群聊只读/发送权限。
|
||||||
|
|
||||||
|
## 开发说明
|
||||||
|
|
||||||
|
Go 侧飞书增强接口位于 `pkg/channels/feishu/`,可直接复用下列能力:
|
||||||
|
|
||||||
|
- `GetMessage` / `GetMessageByID`
|
||||||
|
- `ListMessages`
|
||||||
|
- `ReplyMessage`
|
||||||
|
- `GetUserInfo` / `ListUsers`
|
||||||
|
- `GetUserIDByEmail` / `GetUserIDByMobile`
|
||||||
|
- `GetGroupInfo` / `ListGroupMembers` / `ListGroups` / `CreateGroup`
|
||||||
|
- 入站图片 / 文件 / 音频资源自动下载与存储
|
||||||
|
- `GetMessageFromShareLink`
|
||||||
|
- `GetDriveRootFolder` / `GetDriveFolder` / `GetDriveFile` / `ListDriveFiles`
|
||||||
|
- `UploadDriveFile` / `DownloadDriveFile` / `DeleteDriveFile`
|
||||||
|
- `InitiateMultipartUpload` / `UploadMultipartChunk` / `CompleteMultipartUpload`
|
||||||
|
|
||||||
|
这些方法返回的是 PicoClaw 内部统一结构,而不是直接暴露 SDK 原始类型,便于后续被工具层或技能层复用。
|
||||||
|
|
||||||
|
## 工具层说明
|
||||||
|
|
||||||
|
当前工具层新增了 `feishu_parse`,用于在本地直接解析:
|
||||||
|
|
||||||
|
- 飞书消息内容 JSON
|
||||||
|
- 飞书卡片 JSON
|
||||||
|
- 飞书分享链接中的 token
|
||||||
|
|
||||||
|
这个工具不依赖远程飞书 API,适合在 agent 推理过程中快速理解飞书 payload。
|
||||||
|
|
||||||
|
`feishu_parse` 已经接入默认工具初始化流程;只要在工具配置中启用 `feishu_parse`,agent 就能直接调用。
|
||||||
|
|
||||||
|
同时,工具层已经准备好了 `feishu_remote` 的远程查询/操作接口模型。它支持通过注入的 Feishu 客户端统一暴露:
|
||||||
|
|
||||||
|
- 消息:`get_message`、`list_messages`、`reply_message`、`get_message_from_share_link`
|
||||||
|
- 用户:`get_user`、`list_users`、`get_user_id_by_email`、`get_user_id_by_mobile`
|
||||||
|
- 群组:`create_group`、`get_group`、`list_group_members`、`list_groups`、`send_group_message`
|
||||||
|
- Drive:`get_drive_root_folder`、`get_drive_folder`、`get_drive_file`、`list_drive_files`、`download_drive_file`、`delete_drive_file`、`upload_drive_file`
|
||||||
|
- 大文件上传:`initiate_multipart_upload`、`upload_multipart_chunk`、`complete_multipart_upload`
|
||||||
|
|
||||||
|
当前实现中,当运行时存在已初始化的飞书 channel 时,`AgentLoop.SetChannelManager(...)` 会自动把该 channel 适配为远程客户端,并注册 `feishu_remote` 工具。
|
||||||
|
|
||||||
|
> 说明:工具层并不直接依赖 `pkg/channels/feishu`。当前通过适配器接口完成注入,这样可以保持 channel 层与 tools 层解耦,同时支持未来替换成别的 Feishu client 实现。
|
||||||
|
|
||||||
|
## 集成测试
|
||||||
|
|
||||||
|
仓库中提供了飞书集成测试骨架:`pkg/channels/feishu/integration_test.go`
|
||||||
|
|
||||||
|
运行前请设置:
|
||||||
|
|
||||||
|
- `FEISHU_APP_ID`
|
||||||
|
- `FEISHU_APP_SECRET`
|
||||||
|
|
||||||
|
并使用 Go integration build tag 执行。当前骨架先覆盖参数校验与连通性起点,后续可继续增加真实消息、用户、群聊、Drive 的回归测试。
|
||||||
|
|
|
||||||
4
go.mod
4
go.mod
|
|
@ -4,7 +4,7 @@ go 1.25.7
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/adhocore/gronx v1.19.6
|
github.com/adhocore/gronx v1.19.6
|
||||||
github.com/anthropics/anthropic-sdk-go v1.22.1
|
github.com/anthropics/anthropic-sdk-go v1.26.0
|
||||||
github.com/bwmarrin/discordgo v0.29.0
|
github.com/bwmarrin/discordgo v0.29.0
|
||||||
github.com/caarlos0/env/v11 v11.3.1
|
github.com/caarlos0/env/v11 v11.3.1
|
||||||
github.com/chzyer/readline v1.5.1
|
github.com/chzyer/readline v1.5.1
|
||||||
|
|
@ -91,6 +91,6 @@ require (
|
||||||
golang.org/x/arch v0.24.0 // indirect
|
golang.org/x/arch v0.24.0 // indirect
|
||||||
golang.org/x/crypto v0.48.0 // indirect
|
golang.org/x/crypto v0.48.0 // indirect
|
||||||
golang.org/x/net v0.51.0 // indirect
|
golang.org/x/net v0.51.0 // indirect
|
||||||
golang.org/x/sync v0.19.0 // indirect
|
golang.org/x/sync v0.20.0 // indirect
|
||||||
golang.org/x/sys v0.41.0 // indirect
|
golang.org/x/sys v0.41.0 // indirect
|
||||||
)
|
)
|
||||||
|
|
|
||||||
4
go.sum
4
go.sum
|
|
@ -13,6 +13,8 @@ github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwTo
|
||||||
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
|
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
|
||||||
github.com/anthropics/anthropic-sdk-go v1.22.1 h1:xbsc3vJKCX/ELDZSpTNfz9wCgrFsamwFewPb1iI0Xh0=
|
github.com/anthropics/anthropic-sdk-go v1.22.1 h1:xbsc3vJKCX/ELDZSpTNfz9wCgrFsamwFewPb1iI0Xh0=
|
||||||
github.com/anthropics/anthropic-sdk-go v1.22.1/go.mod h1:WTz31rIUHUHqai2UslPpw5CwXrQP3geYBioRV4WOLvE=
|
github.com/anthropics/anthropic-sdk-go v1.22.1/go.mod h1:WTz31rIUHUHqai2UslPpw5CwXrQP3geYBioRV4WOLvE=
|
||||||
|
github.com/anthropics/anthropic-sdk-go v1.26.0 h1:oUTzFaUpAevfuELAP1sjL6CQJ9HHAfT7CoSYSac11PY=
|
||||||
|
github.com/anthropics/anthropic-sdk-go v1.26.0/go.mod h1:qUKmaW+uuPB64iy1l+4kOSvaLqPXnHTTBKH6RVZ7q5Q=
|
||||||
github.com/beeper/argo-go v1.1.2 h1:UQI2G8F+NLfGTOmTUI0254pGKx/HUU/etbUGTJv91Fs=
|
github.com/beeper/argo-go v1.1.2 h1:UQI2G8F+NLfGTOmTUI0254pGKx/HUU/etbUGTJv91Fs=
|
||||||
github.com/beeper/argo-go v1.1.2/go.mod h1:M+LJAnyowKVQ6Rdj6XYGEn+qcVFkb3R/MUpqkGR0hM4=
|
github.com/beeper/argo-go v1.1.2/go.mod h1:M+LJAnyowKVQ6Rdj6XYGEn+qcVFkb3R/MUpqkGR0hM4=
|
||||||
github.com/bwmarrin/discordgo v0.29.0 h1:FmWeXFaKUwrcL3Cx65c20bTRW+vOb6k8AnaP+EgjDno=
|
github.com/bwmarrin/discordgo v0.29.0 h1:FmWeXFaKUwrcL3Cx65c20bTRW+vOb6k8AnaP+EgjDno=
|
||||||
|
|
@ -284,6 +286,8 @@ golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJ
|
||||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||||
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||||
|
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||||
|
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||||
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
|
|
||||||
|
|
@ -96,6 +96,8 @@ func NewAgentInstance(
|
||||||
toolsRegistry.Register(tools.NewAppendFileTool(workspace, restrict, allowWritePaths))
|
toolsRegistry.Register(tools.NewAppendFileTool(workspace, restrict, allowWritePaths))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
tools.RegisterFeishuTools(toolsRegistry, cfg)
|
||||||
|
|
||||||
sessionsDir := filepath.Join(workspace, "sessions")
|
sessionsDir := filepath.Join(workspace, "sessions")
|
||||||
sessions := initSessionStore(sessionsDir)
|
sessions := initSessionStore(sessionsDir)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -444,6 +444,22 @@ func (al *AgentLoop) RegisterTool(tool tools.Tool) {
|
||||||
|
|
||||||
func (al *AgentLoop) SetChannelManager(cm *channels.Manager) {
|
func (al *AgentLoop) SetChannelManager(cm *channels.Manager) {
|
||||||
al.channelManager = cm
|
al.channelManager = cm
|
||||||
|
if cm == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
feishuCh, ok := cm.GetChannel("feishu")
|
||||||
|
if !ok || feishuCh == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
adapter := tools.NewFeishuChannelAdapter(feishuCh)
|
||||||
|
if !adapter.Ready() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, agentID := range al.registry.ListAgentIDs() {
|
||||||
|
if agent, ok := al.registry.GetAgent(agentID); ok {
|
||||||
|
tools.RegisterFeishuToolsWithClient(agent.Tools, al.cfg, adapter)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetMediaStore injects a MediaStore for media lifecycle management.
|
// SetMediaStore injects a MediaStore for media lifecycle management.
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,24 @@ func (f *fakeChannel) IsAllowed(string) bool {
|
||||||
func (f *fakeChannel) IsAllowedSender(sender bus.SenderInfo) bool { return true }
|
func (f *fakeChannel) IsAllowedSender(sender bus.SenderInfo) bool { return true }
|
||||||
func (f *fakeChannel) ReasoningChannelID() string { return f.id }
|
func (f *fakeChannel) ReasoningChannelID() string { return f.id }
|
||||||
|
|
||||||
|
type fakeFeishuRemoteChannel struct{ fakeChannel }
|
||||||
|
|
||||||
|
func (f *fakeFeishuRemoteChannel) GetMessage(ctx context.Context, messageID string) (any, error) {
|
||||||
|
return map[string]any{"message_id": messageID}, nil
|
||||||
|
}
|
||||||
|
func (f *fakeFeishuRemoteChannel) ListMessages(ctx context.Context, containerID, containerType string, pageSize int, pageToken string) (any, error) {
|
||||||
|
return map[string]any{"container_id": containerID}, nil
|
||||||
|
}
|
||||||
|
func (f *fakeFeishuRemoteChannel) GetUserInfo(ctx context.Context, userID string) (any, error) {
|
||||||
|
return map[string]any{"user_id": userID}, nil
|
||||||
|
}
|
||||||
|
func (f *fakeFeishuRemoteChannel) GetGroupInfo(ctx context.Context, chatID string) (any, error) {
|
||||||
|
return map[string]any{"chat_id": chatID}, nil
|
||||||
|
}
|
||||||
|
func (f *fakeFeishuRemoteChannel) GetDriveFile(ctx context.Context, fileToken string) (any, error) {
|
||||||
|
return map[string]any{"file_token": fileToken}, nil
|
||||||
|
}
|
||||||
|
|
||||||
func newTestAgentLoop(
|
func newTestAgentLoop(
|
||||||
t *testing.T,
|
t *testing.T,
|
||||||
) (al *AgentLoop, cfg *config.Config, msgBus *bus.MessageBus, provider *mockProvider, cleanup func()) {
|
) (al *AgentLoop, cfg *config.Config, msgBus *bus.MessageBus, provider *mockProvider, cleanup func()) {
|
||||||
|
|
@ -1116,3 +1134,41 @@ func TestResolveMediaRefs_UsesMetaContentType(t *testing.T) {
|
||||||
t.Fatalf("expected jpeg prefix, got %q", result[0].Media[0][:30])
|
t.Fatalf("expected jpeg prefix, got %q", result[0].Media[0][:30])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSetChannelManager_RegistersFeishuRemoteTool(t *testing.T) {
|
||||||
|
tmpDir, err := os.MkdirTemp("", "agent-feishu-*")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create temp dir: %v", err)
|
||||||
|
}
|
||||||
|
defer os.RemoveAll(tmpDir)
|
||||||
|
|
||||||
|
cfg := &config.Config{
|
||||||
|
Agents: config.AgentsConfig{
|
||||||
|
Defaults: config.AgentDefaults{
|
||||||
|
Workspace: tmpDir,
|
||||||
|
Model: "test-model",
|
||||||
|
MaxTokens: 4096,
|
||||||
|
MaxToolIterations: 10,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
cfg.Tools.Enabled = []string{"feishu_parse", "feishu_remote"}
|
||||||
|
|
||||||
|
al := NewAgentLoop(cfg, bus.NewMessageBus(), &mockProvider{})
|
||||||
|
chManager, err := channels.NewManager(&config.Config{}, bus.NewMessageBus(), nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create channel manager: %v", err)
|
||||||
|
}
|
||||||
|
chManager.RegisterChannel("feishu", &fakeFeishuRemoteChannel{fakeChannel{id: "rid-feishu"}})
|
||||||
|
|
||||||
|
al.SetChannelManager(chManager)
|
||||||
|
|
||||||
|
defaultAgent := al.registry.GetDefaultAgent()
|
||||||
|
if defaultAgent == nil {
|
||||||
|
t.Fatal("expected default agent")
|
||||||
|
}
|
||||||
|
if _, ok := defaultAgent.Tools.Get("feishu_remote"); !ok {
|
||||||
|
t.Fatal("expected feishu_remote to be registered after channel manager injection")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
|
||||||
415
pkg/channels/feishu/api_helpers.go
Normal file
415
pkg/channels/feishu/api_helpers.go
Normal file
|
|
@ -0,0 +1,415 @@
|
||||||
|
package feishu
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"mime"
|
||||||
|
"mime/multipart"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
func parseMessageContentPayload(messageData map[string]any) *ParsedMessage {
|
||||||
|
if len(messageData) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
items, _ := messageData["items"].([]any)
|
||||||
|
if len(items) == 0 {
|
||||||
|
if msgMap, ok := messageData["message"].(map[string]any); ok {
|
||||||
|
return parseMessageItem(msgMap)
|
||||||
|
}
|
||||||
|
return parseMessageItem(messageData)
|
||||||
|
}
|
||||||
|
first, _ := items[0].(map[string]any)
|
||||||
|
return parseMessageItem(first)
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseMessageItem(item map[string]any) *ParsedMessage {
|
||||||
|
if len(item) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
body, _ := item["body"].(map[string]any)
|
||||||
|
contentStr, _ := body["content"].(string)
|
||||||
|
var content any
|
||||||
|
if contentStr != "" {
|
||||||
|
if err := json.Unmarshal([]byte(contentStr), &content); err != nil {
|
||||||
|
content = contentStr
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return &ParsedMessage{
|
||||||
|
MessageID: stringFromAny(item["message_id"]),
|
||||||
|
ChatID: stringFromAny(item["chat_id"]),
|
||||||
|
MsgType: stringFromAny(item["msg_type"]),
|
||||||
|
CreateTime: stringFromAny(item["create_time"]),
|
||||||
|
Sender: mapFromAny(item["sender"]),
|
||||||
|
Content: content,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseCardSummary(card any) *CardSummary {
|
||||||
|
cardMap, ok := card.(map[string]any)
|
||||||
|
if !ok || len(cardMap) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
summary := &CardSummary{}
|
||||||
|
if title := extractCardTitle(cardMap); title != "" {
|
||||||
|
summary.Title = title
|
||||||
|
}
|
||||||
|
|
||||||
|
components := extractCardComponents(cardMap)
|
||||||
|
for i, component := range components {
|
||||||
|
compMap, _ := component.(map[string]any)
|
||||||
|
if len(compMap) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
parsed := CardComponent{Index: i + 1, Tag: stringFromAny(compMap["tag"]), Details: map[string]any{}}
|
||||||
|
tag := parsed.Tag
|
||||||
|
switch tag {
|
||||||
|
case "img":
|
||||||
|
parsed.Details = map[string]any{
|
||||||
|
"image_key": stringFromAny(compMap["image_key"]),
|
||||||
|
"width": compMap["width"],
|
||||||
|
"height": compMap["height"],
|
||||||
|
"alt": compMap["alt"],
|
||||||
|
}
|
||||||
|
if key := stringFromAny(compMap["image_key"]); key != "" {
|
||||||
|
summary.ImageKeys = append(summary.ImageKeys, key)
|
||||||
|
}
|
||||||
|
case "text", "note", "header":
|
||||||
|
text := extractTextContent(compMap["text"])
|
||||||
|
parsed.Details = map[string]any{"text": text, "style": compMap["style"], "mode": compMap["mode"]}
|
||||||
|
if text != "" {
|
||||||
|
summary.TextContents = append(summary.TextContents, text)
|
||||||
|
}
|
||||||
|
case "div":
|
||||||
|
text := extractTextContent(compMap["text"])
|
||||||
|
parsed.Details = map[string]any{
|
||||||
|
"text": text,
|
||||||
|
"style": compMap["style"],
|
||||||
|
"mode": compMap["mode"],
|
||||||
|
"fields": compMap["fields"],
|
||||||
|
}
|
||||||
|
if text != "" {
|
||||||
|
summary.TextContents = append(summary.TextContents, text)
|
||||||
|
}
|
||||||
|
case "action":
|
||||||
|
actions, _ := compMap["actions"].([]any)
|
||||||
|
parsedActions := make([]map[string]any, 0, len(actions))
|
||||||
|
for _, rawAction := range actions {
|
||||||
|
action, _ := rawAction.(map[string]any)
|
||||||
|
btnText := extractTextContent(action["text"])
|
||||||
|
parsedActions = append(parsedActions, map[string]any{
|
||||||
|
"text": btnText,
|
||||||
|
"type": stringFromAny(action["type"]),
|
||||||
|
"style": mapFromAny(action["style"]),
|
||||||
|
})
|
||||||
|
if btnText != "" {
|
||||||
|
summary.ActionButtons = append(summary.ActionButtons, btnText)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
parsed.Details = map[string]any{"action_count": len(actions), "actions": parsedActions}
|
||||||
|
case "at":
|
||||||
|
parsed.Details = map[string]any{
|
||||||
|
"user_id": stringFromAny(compMap["user_id"]),
|
||||||
|
"user_name": stringFromAny(compMap["user_name"]),
|
||||||
|
"user_avatar": stringFromAny(compMap["user_avatar"]),
|
||||||
|
"style": compMap["style"],
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
parsed.Details = compMap
|
||||||
|
}
|
||||||
|
summary.Components = append(summary.Components, parsed)
|
||||||
|
}
|
||||||
|
|
||||||
|
return summary
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractCardTitle(card map[string]any) string {
|
||||||
|
if title := stringFromAny(card["title"]); title != "" {
|
||||||
|
return title
|
||||||
|
}
|
||||||
|
if header, ok := card["header"].(map[string]any); ok {
|
||||||
|
if titleMap, ok := header["title"].(map[string]any); ok {
|
||||||
|
if text := stringFromAny(titleMap["content"]); text != "" {
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractCardComponents(card map[string]any) []any {
|
||||||
|
if body, ok := card["body"].(map[string]any); ok {
|
||||||
|
if elements, ok := body["elements"].([]any); ok {
|
||||||
|
return elements
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if elements, ok := card["elements"].([]any); ok {
|
||||||
|
return elements
|
||||||
|
}
|
||||||
|
if content, ok := card["content"].([]any); ok {
|
||||||
|
return content
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractTextContent(v any) string {
|
||||||
|
switch vv := v.(type) {
|
||||||
|
case string:
|
||||||
|
return vv
|
||||||
|
case map[string]any:
|
||||||
|
if s := stringFromAny(vv["content"]); s != "" {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
if s := stringFromAny(vv["text"]); s != "" {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractShareLinkToken(shareLink string) string {
|
||||||
|
u, err := url.Parse(shareLink)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return u.Query().Get("token")
|
||||||
|
}
|
||||||
|
|
||||||
|
func mapFromAny(v any) map[string]any {
|
||||||
|
m, _ := v.(map[string]any)
|
||||||
|
if m == nil {
|
||||||
|
return map[string]any{}
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
func stringFromAny(v any) string {
|
||||||
|
switch vv := v.(type) {
|
||||||
|
case string:
|
||||||
|
return vv
|
||||||
|
case *string:
|
||||||
|
if vv != nil {
|
||||||
|
return *vv
|
||||||
|
}
|
||||||
|
case json.Number:
|
||||||
|
return vv.String()
|
||||||
|
case float64:
|
||||||
|
return strconv.FormatFloat(vv, 'f', -1, 64)
|
||||||
|
case int:
|
||||||
|
return strconv.Itoa(vv)
|
||||||
|
case int64:
|
||||||
|
return strconv.FormatInt(vv, 10)
|
||||||
|
case bool:
|
||||||
|
return strconv.FormatBool(vv)
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func boolFromAny(v any) bool {
|
||||||
|
switch vv := v.(type) {
|
||||||
|
case bool:
|
||||||
|
return vv
|
||||||
|
case string:
|
||||||
|
return strings.EqualFold(vv, "true")
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func int64FromAny(v any) int64 {
|
||||||
|
switch vv := v.(type) {
|
||||||
|
case int:
|
||||||
|
return int64(vv)
|
||||||
|
case int64:
|
||||||
|
return vv
|
||||||
|
case float64:
|
||||||
|
return int64(vv)
|
||||||
|
case json.Number:
|
||||||
|
n, _ := vv.Int64()
|
||||||
|
return n
|
||||||
|
case string:
|
||||||
|
n, _ := strconv.ParseInt(strings.TrimSpace(vv), 10, 64)
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeMessageDetail(item map[string]any) MessageDetail {
|
||||||
|
detail := MessageDetail{
|
||||||
|
MessageID: stringFromAny(item["message_id"]),
|
||||||
|
ChatID: stringFromAny(item["chat_id"]),
|
||||||
|
RootID: stringFromAny(item["root_id"]),
|
||||||
|
ParentID: stringFromAny(item["parent_id"]),
|
||||||
|
MsgType: stringFromAny(item["msg_type"]),
|
||||||
|
Deleted: boolFromAny(item["deleted"]),
|
||||||
|
Updated: boolFromAny(item["updated"]),
|
||||||
|
CreateTime: stringFromAny(item["create_time"]),
|
||||||
|
UpdateTime: stringFromAny(item["update_time"]),
|
||||||
|
Sender: mapFromAny(item["sender"]),
|
||||||
|
Body: mapFromAny(item["body"]),
|
||||||
|
Raw: item,
|
||||||
|
}
|
||||||
|
if mentions, ok := item["mentions"].([]any); ok {
|
||||||
|
for _, mention := range mentions {
|
||||||
|
if m, ok := mention.(map[string]any); ok {
|
||||||
|
detail.Mentions = append(detail.Mentions, m)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if parsed := parseMessageItem(item); parsed != nil {
|
||||||
|
detail.Parsed = parsed
|
||||||
|
if parsed.MsgType == larkInteractiveMsgType() {
|
||||||
|
detail.CardParsed = parseCardSummary(parsed.Content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return detail
|
||||||
|
}
|
||||||
|
|
||||||
|
func larkInteractiveMsgType() string { return "interactive" }
|
||||||
|
|
||||||
|
func normalizeDriveFile(item map[string]any) DriveFileSummary {
|
||||||
|
return DriveFileSummary{
|
||||||
|
FileToken: firstNonEmpty(stringFromAny(item["file_token"]), stringFromAny(item["token"]), stringFromAny(item["file_key"])),
|
||||||
|
Name: firstNonEmpty(stringFromAny(item["name"]), stringFromAny(item["file_name"])),
|
||||||
|
Type: stringFromAny(item["type"]),
|
||||||
|
ParentToken: firstNonEmpty(stringFromAny(item["parent_token"]), stringFromAny(item["parent_node"])),
|
||||||
|
Size: int64FromAny(item["size"]),
|
||||||
|
Extension: firstNonEmpty(stringFromAny(item["extension"]), stringFromAny(item["file_extension"])),
|
||||||
|
MimeType: firstNonEmpty(stringFromAny(item["mime_type"]), stringFromAny(item["content_type"])),
|
||||||
|
URL: firstNonEmpty(stringFromAny(item["url"]), stringFromAny(item["download_url"])),
|
||||||
|
CreatedTime: stringFromAny(item["created_time"]),
|
||||||
|
ModifiedTime: stringFromAny(item["modified_time"]),
|
||||||
|
Raw: item,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeDriveFolder(item map[string]any) DriveFolderSummary {
|
||||||
|
return DriveFolderSummary{
|
||||||
|
FolderToken: firstNonEmpty(stringFromAny(item["folder_token"]), stringFromAny(item["token"])),
|
||||||
|
Name: firstNonEmpty(stringFromAny(item["name"]), stringFromAny(item["folder_name"])),
|
||||||
|
ParentToken: firstNonEmpty(stringFromAny(item["parent_token"]), stringFromAny(item["parent_node"])),
|
||||||
|
URL: firstNonEmpty(stringFromAny(item["url"]), stringFromAny(item["folder_url"])),
|
||||||
|
CreatedTime: stringFromAny(item["created_time"]),
|
||||||
|
ModifiedTime: stringFromAny(item["modified_time"]),
|
||||||
|
Raw: item,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func filenameFromHeader(h http.Header) string {
|
||||||
|
cd := h.Get("Content-Disposition")
|
||||||
|
if cd == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
_, params, err := mime.ParseMediaType(cd)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if v := params["filename*"]; v != "" {
|
||||||
|
if i := strings.Index(v, "''"); i >= 0 && i+2 < len(v) {
|
||||||
|
if decoded, err := url.QueryUnescape(v[i+2:]); err == nil {
|
||||||
|
return decoded
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
return params["filename"]
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildDriveUploadBody(parentToken, name string, r io.Reader) (*bytes.Buffer, string, error) {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
w := multipart.NewWriter(&buf)
|
||||||
|
if err := w.WriteField("file_name", name); err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
if parentToken != "" {
|
||||||
|
if err := w.WriteField("parent_type", "explorer"); err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
if err := w.WriteField("parent_node", parentToken); err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
part, err := w.CreateFormFile("file", filepath.Base(name))
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
if _, err := io.Copy(part, r); err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
if err := w.Close(); err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
return &buf, w.FormDataContentType(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildMultipartChunkBody(seq int, data []byte) (*bytes.Buffer, string, error) {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
w := multipart.NewWriter(&buf)
|
||||||
|
if err := w.WriteField("seq", strconv.Itoa(seq)); err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
part, err := w.CreateFormFile("file", "chunk")
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
if _, err := part.Write(data); err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
if err := w.Close(); err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
return &buf, w.FormDataContentType(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeUserSummary(item map[string]any) UserSummary {
|
||||||
|
return UserSummary{
|
||||||
|
ID: firstNonEmpty(stringFromAny(item["id"]), stringFromAny(item["open_id"]), stringFromAny(item["user_id"])),
|
||||||
|
OpenID: stringFromAny(item["open_id"]),
|
||||||
|
UserID: stringFromAny(item["user_id"]),
|
||||||
|
UnionID: stringFromAny(item["union_id"]),
|
||||||
|
Name: stringFromAny(item["name"]),
|
||||||
|
EnName: stringFromAny(item["en_name"]),
|
||||||
|
Email: stringFromAny(item["email"]),
|
||||||
|
Mobile: stringFromAny(item["mobile"]),
|
||||||
|
AvatarURL: stringFromAny(item["avatar_url"]),
|
||||||
|
Status: mapFromAny(item["status"]),
|
||||||
|
Raw: item,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeChatSummary(item map[string]any) ChatSummary {
|
||||||
|
ownerID := stringFromAny(item["owner_id"])
|
||||||
|
if ownerID == "" {
|
||||||
|
if ownerMap, ok := item["owner_id_type"].(map[string]any); ok {
|
||||||
|
ownerID = stringFromAny(ownerMap["user_id"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ChatSummary{
|
||||||
|
ChatID: firstNonEmpty(stringFromAny(item["chat_id"]), stringFromAny(item["open_chat_id"])),
|
||||||
|
Name: stringFromAny(item["name"]),
|
||||||
|
Description: stringFromAny(item["description"]),
|
||||||
|
ChatMode: stringFromAny(item["chat_mode"]),
|
||||||
|
ChatType: stringFromAny(item["chat_type"]),
|
||||||
|
OwnerID: ownerID,
|
||||||
|
OwnerOpenID: stringFromAny(item["owner_open_id"]),
|
||||||
|
External: boolFromAny(item["external"]),
|
||||||
|
TenantKey: stringFromAny(item["tenant_key"]),
|
||||||
|
Avatar: firstNonEmpty(stringFromAny(item["avatar"]), stringFromAny(item["avatar_path"])),
|
||||||
|
Raw: item,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func firstNonEmpty(values ...string) string {
|
||||||
|
for _, value := range values {
|
||||||
|
if strings.TrimSpace(value) != "" {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
170
pkg/channels/feishu/api_helpers_test.go
Normal file
170
pkg/channels/feishu/api_helpers_test.go
Normal file
|
|
@ -0,0 +1,170 @@
|
||||||
|
package feishu
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestParseMessageContentPayload(t *testing.T) {
|
||||||
|
payload := map[string]any{
|
||||||
|
"items": []any{
|
||||||
|
map[string]any{
|
||||||
|
"message_id": "om_123",
|
||||||
|
"chat_id": "oc_456",
|
||||||
|
"msg_type": "text",
|
||||||
|
"create_time": "1710000000",
|
||||||
|
"sender": map[string]any{"id": "ou_1"},
|
||||||
|
"body": map[string]any{"content": `{"text":"hello"}`},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
parsed := parseMessageContentPayload(payload)
|
||||||
|
if parsed == nil {
|
||||||
|
t.Fatal("expected parsed payload")
|
||||||
|
}
|
||||||
|
if parsed.MessageID != "om_123" || parsed.ChatID != "oc_456" || parsed.MsgType != "text" {
|
||||||
|
t.Fatalf("unexpected parsed message: %+v", parsed)
|
||||||
|
}
|
||||||
|
content, ok := parsed.Content.(map[string]any)
|
||||||
|
if !ok || content["text"] != "hello" {
|
||||||
|
t.Fatalf("unexpected parsed content: %#v", parsed.Content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseCardSummary(t *testing.T) {
|
||||||
|
card := map[string]any{
|
||||||
|
"header": map[string]any{"title": map[string]any{"content": "test title"}},
|
||||||
|
"elements": []any{
|
||||||
|
map[string]any{"tag": "div", "text": map[string]any{"content": "body text"}},
|
||||||
|
map[string]any{"tag": "img", "image_key": "img_xxx"},
|
||||||
|
map[string]any{"tag": "action", "actions": []any{
|
||||||
|
map[string]any{"text": map[string]any{"content": "Confirm"}, "type": "primary"},
|
||||||
|
}},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
summary := parseCardSummary(card)
|
||||||
|
if summary == nil {
|
||||||
|
t.Fatal("expected card summary")
|
||||||
|
}
|
||||||
|
if summary.Title != "test title" {
|
||||||
|
t.Fatalf("unexpected title: %q", summary.Title)
|
||||||
|
}
|
||||||
|
if len(summary.ImageKeys) != 1 || summary.ImageKeys[0] != "img_xxx" {
|
||||||
|
t.Fatalf("unexpected image keys: %#v", summary.ImageKeys)
|
||||||
|
}
|
||||||
|
if len(summary.TextContents) == 0 || summary.TextContents[0] != "body text" {
|
||||||
|
t.Fatalf("unexpected text contents: %#v", summary.TextContents)
|
||||||
|
}
|
||||||
|
if len(summary.ActionButtons) != 1 || summary.ActionButtons[0] != "Confirm" {
|
||||||
|
t.Fatalf("unexpected action buttons: %#v", summary.ActionButtons)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtractShareLinkToken(t *testing.T) {
|
||||||
|
link := "https://applink.feishu.cn/client/message/link/open?token=om_abc%3D%3D&foo=bar"
|
||||||
|
token := extractShareLinkToken(link)
|
||||||
|
if token != "om_abc==" {
|
||||||
|
t.Fatalf("unexpected token: %q", token)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNormalizeMessageDetailInteractive(t *testing.T) {
|
||||||
|
item := map[string]any{
|
||||||
|
"message_id": "om_123",
|
||||||
|
"chat_id": "oc_123",
|
||||||
|
"msg_type": "interactive",
|
||||||
|
"body": map[string]any{
|
||||||
|
"content": `{"header":{"title":{"content":"hello"}},"elements":[{"tag":"div","text":{"content":"world"}}]}`,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
detail := normalizeMessageDetail(item)
|
||||||
|
if detail.Parsed == nil {
|
||||||
|
t.Fatal("expected parsed detail")
|
||||||
|
}
|
||||||
|
if detail.CardParsed == nil || detail.CardParsed.Title != "hello" {
|
||||||
|
t.Fatalf("unexpected card parse: %#v", detail.CardParsed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNormalizeUserSummary(t *testing.T) {
|
||||||
|
user := normalizeUserSummary(map[string]any{"open_id": "ou_1", "name": "Alice", "email": "a@example.com"})
|
||||||
|
if user.ID != "ou_1" || user.Name != "Alice" || user.Email != "a@example.com" {
|
||||||
|
t.Fatalf("unexpected user summary: %+v", user)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNormalizeChatSummary(t *testing.T) {
|
||||||
|
chat := normalizeChatSummary(map[string]any{"chat_id": "oc_1", "name": "Group", "description": "desc"})
|
||||||
|
if chat.ChatID != "oc_1" || chat.Name != "Group" || chat.Description != "desc" {
|
||||||
|
t.Fatalf("unexpected chat summary: %+v", chat)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFilenameFromHeader(t *testing.T) {
|
||||||
|
h := http.Header{}
|
||||||
|
h.Set("Content-Disposition", `attachment; filename="demo.txt"`)
|
||||||
|
if got := filenameFromHeader(h); got != "demo.txt" {
|
||||||
|
t.Fatalf("unexpected filename: %s", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFilenameFromHeaderEncoded(t *testing.T) {
|
||||||
|
h := http.Header{}
|
||||||
|
h.Set("Content-Disposition", "attachment; filename*=UTF-8''hello%20world.txt")
|
||||||
|
if got := filenameFromHeader(h); got != "hello world.txt" {
|
||||||
|
t.Fatalf("unexpected encoded filename: %s", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNormalizeDriveFile(t *testing.T) {
|
||||||
|
got := normalizeDriveFile(map[string]any{
|
||||||
|
"file_token": "file_token",
|
||||||
|
"name": "demo.txt",
|
||||||
|
"type": "file",
|
||||||
|
"parent_token": "folder_token",
|
||||||
|
"size": float64(12),
|
||||||
|
})
|
||||||
|
if got.FileToken != "file_token" || got.Name != "demo.txt" || got.Size != 12 {
|
||||||
|
t.Fatalf("unexpected normalized file: %+v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildMultipartChunkBody(t *testing.T) {
|
||||||
|
body, contentType, err := buildMultipartChunkBody(2, []byte("hello"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("build body failed: %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(contentType, "multipart/form-data") {
|
||||||
|
t.Fatalf("unexpected content type: %s", contentType)
|
||||||
|
}
|
||||||
|
b, err := io.ReadAll(body)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read body failed: %v", err)
|
||||||
|
}
|
||||||
|
s := string(b)
|
||||||
|
if !strings.Contains(s, `name="seq"`) || !strings.Contains(s, "2") {
|
||||||
|
t.Fatalf("unexpected multipart body: %s", s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildDriveUploadBody(t *testing.T) {
|
||||||
|
body, contentType, err := buildDriveUploadBody("fld_123", "report.txt", strings.NewReader("hello"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("build upload body failed: %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(contentType, "multipart/form-data") {
|
||||||
|
t.Fatalf("unexpected content type: %s", contentType)
|
||||||
|
}
|
||||||
|
b, err := io.ReadAll(body)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read body failed: %v", err)
|
||||||
|
}
|
||||||
|
s := string(b)
|
||||||
|
for _, expected := range []string{"fld_123", "report.txt", "hello", `name="file"`} {
|
||||||
|
if !strings.Contains(s, expected) {
|
||||||
|
t.Fatalf("multipart body missing %q: %s", expected, s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
691
pkg/channels/feishu/api_methods.go
Normal file
691
pkg/channels/feishu/api_methods.go
Normal file
|
|
@ -0,0 +1,691 @@
|
||||||
|
//go:build amd64 || arm64 || riscv64 || mips64 || ppc64
|
||||||
|
|
||||||
|
package feishu
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||||
|
larkim "github.com/larksuite/oapi-sdk-go/v3/service/im/v1"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (c *FeishuChannel) GetMessage(ctx context.Context, messageID string) (*MessageDetail, error) {
|
||||||
|
if strings.TrimSpace(messageID) == "" {
|
||||||
|
return nil, fmt.Errorf("message ID is empty")
|
||||||
|
}
|
||||||
|
var payload struct {
|
||||||
|
Code int `json:"code"`
|
||||||
|
Msg string `json:"msg"`
|
||||||
|
Data map[string]any `json:"data"`
|
||||||
|
}
|
||||||
|
if err := c.getJSON(ctx, "/open-apis/im/v1/messages/"+url.PathEscape(messageID), nil, &payload); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if payload.Code != 0 {
|
||||||
|
return nil, fmt.Errorf("feishu get message api error (code=%d msg=%s)", payload.Code, payload.Msg)
|
||||||
|
}
|
||||||
|
item := payload.Data
|
||||||
|
if message, ok := payload.Data["message"].(map[string]any); ok {
|
||||||
|
item = message
|
||||||
|
}
|
||||||
|
detail := normalizeMessageDetail(item)
|
||||||
|
return &detail, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *FeishuChannel) GetMessageByID(ctx context.Context, messageID string) (*MessageDetail, error) {
|
||||||
|
return c.GetMessage(ctx, messageID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *FeishuChannel) ListMessages(ctx context.Context, containerID, containerType string, pageSize int, pageToken string) (*MessageList, error) {
|
||||||
|
if strings.TrimSpace(containerID) == "" {
|
||||||
|
return nil, fmt.Errorf("container ID is empty")
|
||||||
|
}
|
||||||
|
if containerType == "" {
|
||||||
|
containerType = "chat"
|
||||||
|
}
|
||||||
|
if pageSize <= 0 {
|
||||||
|
pageSize = 20
|
||||||
|
}
|
||||||
|
query := map[string]string{
|
||||||
|
"container_id": containerID,
|
||||||
|
"container_id_type": containerType,
|
||||||
|
"page_size": fmt.Sprintf("%d", pageSize),
|
||||||
|
}
|
||||||
|
if pageToken != "" {
|
||||||
|
query["page_token"] = pageToken
|
||||||
|
}
|
||||||
|
var payload struct {
|
||||||
|
Code int `json:"code"`
|
||||||
|
Msg string `json:"msg"`
|
||||||
|
Data struct {
|
||||||
|
Items []map[string]any `json:"items"`
|
||||||
|
HasMore bool `json:"has_more"`
|
||||||
|
PageToken string `json:"page_token"`
|
||||||
|
} `json:"data"`
|
||||||
|
}
|
||||||
|
if err := c.getJSON(ctx, "/open-apis/im/v1/messages", query, &payload); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if payload.Code != 0 {
|
||||||
|
return nil, fmt.Errorf("feishu list messages api error (code=%d msg=%s)", payload.Code, payload.Msg)
|
||||||
|
}
|
||||||
|
result := &MessageList{HasMore: payload.Data.HasMore, PageToken: payload.Data.PageToken}
|
||||||
|
for _, item := range payload.Data.Items {
|
||||||
|
result.Items = append(result.Items, normalizeMessageDetail(item))
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *FeishuChannel) ReplyMessage(ctx context.Context, messageID, text string) error {
|
||||||
|
if strings.TrimSpace(messageID) == "" {
|
||||||
|
return fmt.Errorf("message ID is empty")
|
||||||
|
}
|
||||||
|
req := larkim.NewReplyMessageReqBuilder().
|
||||||
|
MessageId(messageID).
|
||||||
|
Body(larkim.NewReplyMessageReqBodyBuilder().
|
||||||
|
Content(string(mustJSONMarshal(map[string]string{"text": text}))).
|
||||||
|
MsgType(larkim.MsgTypeText).
|
||||||
|
Build()).
|
||||||
|
Build()
|
||||||
|
resp, err := c.client.Im.V1.Message.Reply(ctx, req)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("feishu reply message: %w", err)
|
||||||
|
}
|
||||||
|
if !resp.Success() {
|
||||||
|
return fmt.Errorf("feishu reply message api error (code=%d msg=%s)", resp.Code, resp.Msg)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *FeishuChannel) ParseMessageContent(raw map[string]any) *ParsedMessage {
|
||||||
|
return parseMessageContentPayload(raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *FeishuChannel) GetMessageFromShareLink(ctx context.Context, shareLink string) (*ShareLinkLookupResult, error) {
|
||||||
|
token := extractShareLinkToken(shareLink)
|
||||||
|
if token == "" {
|
||||||
|
return nil, fmt.Errorf("invalid share link format")
|
||||||
|
}
|
||||||
|
decoded, _ := url.QueryUnescape(token)
|
||||||
|
result := &ShareLinkLookupResult{Token: token, DecodedMessageID: decoded}
|
||||||
|
message, err := c.GetMessage(ctx, decoded)
|
||||||
|
if err == nil {
|
||||||
|
result.Message = message
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
result.FallbackError = err.Error()
|
||||||
|
message, err = c.GetMessage(ctx, token)
|
||||||
|
if err == nil {
|
||||||
|
result.Message = message
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
result.FallbackError = strings.Trim(strings.Join([]string{result.FallbackError, err.Error()}, "; "), "; ")
|
||||||
|
}
|
||||||
|
return result, fmt.Errorf("unable to resolve message from share link")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *FeishuChannel) GetUserInfo(ctx context.Context, userID string) (*UserSummary, error) {
|
||||||
|
if userID == "" {
|
||||||
|
return nil, fmt.Errorf("user ID is empty")
|
||||||
|
}
|
||||||
|
var payload struct {
|
||||||
|
Code int `json:"code"`
|
||||||
|
Msg string `json:"msg"`
|
||||||
|
Data struct {
|
||||||
|
User map[string]any `json:"user"`
|
||||||
|
} `json:"data"`
|
||||||
|
}
|
||||||
|
if err := c.getJSON(ctx, "/open-apis/contact/v3/users/"+url.PathEscape(userID), nil, &payload); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if payload.Code != 0 {
|
||||||
|
return nil, fmt.Errorf("feishu get user api error (code=%d msg=%s)", payload.Code, payload.Msg)
|
||||||
|
}
|
||||||
|
summary := normalizeUserSummary(payload.Data.User)
|
||||||
|
return &summary, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *FeishuChannel) ListUsers(ctx context.Context, pageSize int, userIDType, pageToken string) (*UserList, error) {
|
||||||
|
if pageSize <= 0 {
|
||||||
|
pageSize = 50
|
||||||
|
}
|
||||||
|
if userIDType == "" {
|
||||||
|
userIDType = "open_id"
|
||||||
|
}
|
||||||
|
query := map[string]string{"page_size": fmt.Sprintf("%d", pageSize), "user_id_type": userIDType}
|
||||||
|
if pageToken != "" {
|
||||||
|
query["page_token"] = pageToken
|
||||||
|
}
|
||||||
|
var payload struct {
|
||||||
|
Code int `json:"code"`
|
||||||
|
Msg string `json:"msg"`
|
||||||
|
Data struct {
|
||||||
|
HasMore bool `json:"has_more"`
|
||||||
|
PageToken string `json:"page_token"`
|
||||||
|
Items []map[string]any `json:"items"`
|
||||||
|
Data struct {
|
||||||
|
HasMore bool `json:"has_more"`
|
||||||
|
PageToken string `json:"page_token"`
|
||||||
|
Items []map[string]any `json:"items"`
|
||||||
|
} `json:"data"`
|
||||||
|
} `json:"data"`
|
||||||
|
}
|
||||||
|
if err := c.getJSON(ctx, "/open-apis/contact/v3/users", query, &payload); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if payload.Code != 0 {
|
||||||
|
return nil, fmt.Errorf("feishu list users api error (code=%d msg=%s)", payload.Code, payload.Msg)
|
||||||
|
}
|
||||||
|
data := payload.Data
|
||||||
|
items := data.Items
|
||||||
|
hasMore := data.HasMore
|
||||||
|
nextToken := data.PageToken
|
||||||
|
if len(items) == 0 && len(data.Data.Items) > 0 {
|
||||||
|
items = data.Data.Items
|
||||||
|
hasMore = data.Data.HasMore
|
||||||
|
nextToken = data.Data.PageToken
|
||||||
|
}
|
||||||
|
result := &UserList{HasMore: hasMore, PageToken: nextToken}
|
||||||
|
for _, item := range items {
|
||||||
|
result.Items = append(result.Items, normalizeUserSummary(item))
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *FeishuChannel) GetUserIDByEmail(ctx context.Context, email string) (string, error) {
|
||||||
|
users, err := c.ListUsers(ctx, 100, "open_id", "")
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
for _, user := range users.Items {
|
||||||
|
if strings.EqualFold(user.Email, email) {
|
||||||
|
return firstNonEmpty(user.OpenID, user.ID, user.UserID), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *FeishuChannel) GetUserIDByMobile(ctx context.Context, mobile string) (string, error) {
|
||||||
|
query := map[string]string{"mobile_phone": mobile, "user_id_type": "open_id", "page_size": "1"}
|
||||||
|
var payload struct {
|
||||||
|
Code int `json:"code"`
|
||||||
|
Msg string `json:"msg"`
|
||||||
|
Data struct {
|
||||||
|
Items []map[string]any `json:"items"`
|
||||||
|
} `json:"data"`
|
||||||
|
}
|
||||||
|
if err := c.getJSON(ctx, "/open-apis/contact/v3/users", query, &payload); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if payload.Code != 0 {
|
||||||
|
return "", fmt.Errorf("feishu get user by mobile api error (code=%d msg=%s)", payload.Code, payload.Msg)
|
||||||
|
}
|
||||||
|
if len(payload.Data.Items) == 0 {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
user := normalizeUserSummary(payload.Data.Items[0])
|
||||||
|
return firstNonEmpty(user.OpenID, user.ID, user.UserID), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *FeishuChannel) CreateGroup(ctx context.Context, name string) (*ChatSummary, error) {
|
||||||
|
if strings.TrimSpace(name) == "" {
|
||||||
|
return nil, fmt.Errorf("group name is empty")
|
||||||
|
}
|
||||||
|
body := map[string]any{"name": name, "member_type": "ADMIN"}
|
||||||
|
var payload struct {
|
||||||
|
Code int `json:"code"`
|
||||||
|
Msg string `json:"msg"`
|
||||||
|
Data struct {
|
||||||
|
Chat map[string]any `json:"chat"`
|
||||||
|
} `json:"data"`
|
||||||
|
}
|
||||||
|
if err := c.postJSON(ctx, "/open-apis/im/v1/chats", body, &payload); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if payload.Code != 0 {
|
||||||
|
return nil, fmt.Errorf("feishu create group api error (code=%d msg=%s)", payload.Code, payload.Msg)
|
||||||
|
}
|
||||||
|
chat := normalizeChatSummary(payload.Data.Chat)
|
||||||
|
return &chat, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *FeishuChannel) GetGroupInfo(ctx context.Context, chatID string) (*ChatSummary, error) {
|
||||||
|
if strings.TrimSpace(chatID) == "" {
|
||||||
|
return nil, fmt.Errorf("chat ID is empty")
|
||||||
|
}
|
||||||
|
var payload struct {
|
||||||
|
Code int `json:"code"`
|
||||||
|
Msg string `json:"msg"`
|
||||||
|
Data struct {
|
||||||
|
Chat map[string]any `json:"chat"`
|
||||||
|
} `json:"data"`
|
||||||
|
}
|
||||||
|
if err := c.getJSON(ctx, "/open-apis/im/v1/chats/"+url.PathEscape(chatID), nil, &payload); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if payload.Code != 0 {
|
||||||
|
return nil, fmt.Errorf("feishu get chat api error (code=%d msg=%s)", payload.Code, payload.Msg)
|
||||||
|
}
|
||||||
|
chat := normalizeChatSummary(payload.Data.Chat)
|
||||||
|
return &chat, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *FeishuChannel) ListGroupMembers(ctx context.Context, chatID string, pageSize int, pageToken string) (*UserList, error) {
|
||||||
|
if strings.TrimSpace(chatID) == "" {
|
||||||
|
return nil, fmt.Errorf("chat ID is empty")
|
||||||
|
}
|
||||||
|
if pageSize <= 0 {
|
||||||
|
pageSize = 50
|
||||||
|
}
|
||||||
|
query := map[string]string{"page_size": fmt.Sprintf("%d", pageSize)}
|
||||||
|
if pageToken != "" {
|
||||||
|
query["page_token"] = pageToken
|
||||||
|
}
|
||||||
|
var payload struct {
|
||||||
|
Code int `json:"code"`
|
||||||
|
Msg string `json:"msg"`
|
||||||
|
Data struct {
|
||||||
|
HasMore bool `json:"has_more"`
|
||||||
|
PageToken string `json:"page_token"`
|
||||||
|
Items []map[string]any `json:"items"`
|
||||||
|
} `json:"data"`
|
||||||
|
}
|
||||||
|
if err := c.getJSON(ctx, "/open-apis/im/v1/chats/"+url.PathEscape(chatID)+"/members", query, &payload); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if payload.Code != 0 {
|
||||||
|
return nil, fmt.Errorf("feishu list chat members api error (code=%d msg=%s)", payload.Code, payload.Msg)
|
||||||
|
}
|
||||||
|
result := &UserList{HasMore: payload.Data.HasMore, PageToken: payload.Data.PageToken}
|
||||||
|
for _, item := range payload.Data.Items {
|
||||||
|
result.Items = append(result.Items, normalizeUserSummary(item))
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *FeishuChannel) ListGroups(ctx context.Context, pageSize int, pageToken string) (*ChatList, error) {
|
||||||
|
if pageSize <= 0 {
|
||||||
|
pageSize = 50
|
||||||
|
}
|
||||||
|
query := map[string]string{"page_size": fmt.Sprintf("%d", pageSize)}
|
||||||
|
if pageToken != "" {
|
||||||
|
query["page_token"] = pageToken
|
||||||
|
}
|
||||||
|
var payload struct {
|
||||||
|
Code int `json:"code"`
|
||||||
|
Msg string `json:"msg"`
|
||||||
|
Data struct {
|
||||||
|
HasMore bool `json:"has_more"`
|
||||||
|
PageToken string `json:"page_token"`
|
||||||
|
Items []map[string]any `json:"items"`
|
||||||
|
Data struct {
|
||||||
|
HasMore bool `json:"has_more"`
|
||||||
|
PageToken string `json:"page_token"`
|
||||||
|
Items []map[string]any `json:"items"`
|
||||||
|
} `json:"data"`
|
||||||
|
} `json:"data"`
|
||||||
|
}
|
||||||
|
if err := c.getJSON(ctx, "/open-apis/im/v1/chats", query, &payload); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if payload.Code != 0 {
|
||||||
|
return nil, fmt.Errorf("feishu list chats api error (code=%d msg=%s)", payload.Code, payload.Msg)
|
||||||
|
}
|
||||||
|
items := payload.Data.Items
|
||||||
|
hasMore := payload.Data.HasMore
|
||||||
|
nextToken := payload.Data.PageToken
|
||||||
|
if len(items) == 0 && len(payload.Data.Data.Items) > 0 {
|
||||||
|
items = payload.Data.Data.Items
|
||||||
|
hasMore = payload.Data.Data.HasMore
|
||||||
|
nextToken = payload.Data.Data.PageToken
|
||||||
|
}
|
||||||
|
result := &ChatList{HasMore: hasMore, PageToken: nextToken}
|
||||||
|
for _, item := range items {
|
||||||
|
result.Items = append(result.Items, normalizeChatSummary(item))
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *FeishuChannel) SendGroupMessage(ctx context.Context, chatID, text string) error {
|
||||||
|
req := larkim.NewCreateMessageReqBuilder().
|
||||||
|
ReceiveIdType(larkim.ReceiveIdTypeChatId).
|
||||||
|
Body(larkim.NewCreateMessageReqBodyBuilder().
|
||||||
|
ReceiveId(chatID).
|
||||||
|
MsgType(larkim.MsgTypeText).
|
||||||
|
Content(string(mustJSONMarshal(map[string]string{"text": text}))).
|
||||||
|
Build()).
|
||||||
|
Build()
|
||||||
|
resp, err := c.client.Im.V1.Message.Create(ctx, req)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("feishu send group message: %w", err)
|
||||||
|
}
|
||||||
|
if !resp.Success() {
|
||||||
|
return fmt.Errorf("feishu send group message api error (code=%d msg=%s)", resp.Code, resp.Msg)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *FeishuChannel) GetDriveRootFolder(ctx context.Context) (*DriveFolderSummary, error) {
|
||||||
|
var payload struct {
|
||||||
|
Code int `json:"code"`
|
||||||
|
Msg string `json:"msg"`
|
||||||
|
Data map[string]any `json:"data"`
|
||||||
|
}
|
||||||
|
if err := c.getJSON(ctx, "/open-apis/drive/explorer/v2/root_folder/meta", nil, &payload); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if payload.Code != 0 {
|
||||||
|
return nil, fmt.Errorf("feishu drive root folder api error (code=%d msg=%s)", payload.Code, payload.Msg)
|
||||||
|
}
|
||||||
|
folder := normalizeDriveFolder(payload.Data)
|
||||||
|
return &folder, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *FeishuChannel) GetDriveFolder(ctx context.Context, folderToken string) (*DriveFolderSummary, error) {
|
||||||
|
if strings.TrimSpace(folderToken) == "" {
|
||||||
|
return nil, fmt.Errorf("folder token is empty")
|
||||||
|
}
|
||||||
|
var payload struct {
|
||||||
|
Code int `json:"code"`
|
||||||
|
Msg string `json:"msg"`
|
||||||
|
Data map[string]any `json:"data"`
|
||||||
|
}
|
||||||
|
path := "/open-apis/drive/explorer/v2/folder/" + url.PathEscape(folderToken) + "/meta"
|
||||||
|
if err := c.getJSON(ctx, path, nil, &payload); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if payload.Code != 0 {
|
||||||
|
return nil, fmt.Errorf("feishu get drive folder api error (code=%d msg=%s)", payload.Code, payload.Msg)
|
||||||
|
}
|
||||||
|
folder := normalizeDriveFolder(payload.Data)
|
||||||
|
return &folder, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *FeishuChannel) GetDriveFile(ctx context.Context, fileToken string) (*DriveFileSummary, error) {
|
||||||
|
if strings.TrimSpace(fileToken) == "" {
|
||||||
|
return nil, fmt.Errorf("file token is empty")
|
||||||
|
}
|
||||||
|
var payload struct {
|
||||||
|
Code int `json:"code"`
|
||||||
|
Msg string `json:"msg"`
|
||||||
|
Data struct {
|
||||||
|
File map[string]any `json:"file"`
|
||||||
|
} `json:"data"`
|
||||||
|
}
|
||||||
|
path := "/open-apis/drive/v1/files/" + url.PathEscape(fileToken)
|
||||||
|
if err := c.getJSON(ctx, path, nil, &payload); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if payload.Code != 0 {
|
||||||
|
return nil, fmt.Errorf("feishu get drive file api error (code=%d msg=%s)", payload.Code, payload.Msg)
|
||||||
|
}
|
||||||
|
file := normalizeDriveFile(payload.Data.File)
|
||||||
|
return &file, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *FeishuChannel) ListDriveFiles(ctx context.Context, folderToken, pageToken string, pageSize int) (*DriveFileList, error) {
|
||||||
|
query := map[string]string{}
|
||||||
|
if folderToken != "" {
|
||||||
|
query["folder_token"] = folderToken
|
||||||
|
}
|
||||||
|
if pageToken != "" {
|
||||||
|
query["page_token"] = pageToken
|
||||||
|
}
|
||||||
|
if pageSize > 0 {
|
||||||
|
query["page_size"] = fmt.Sprintf("%d", pageSize)
|
||||||
|
}
|
||||||
|
var payload struct {
|
||||||
|
Code int `json:"code"`
|
||||||
|
Msg string `json:"msg"`
|
||||||
|
Data struct {
|
||||||
|
HasMore bool `json:"has_more"`
|
||||||
|
PageToken string `json:"page_token"`
|
||||||
|
Files []map[string]any `json:"files"`
|
||||||
|
Items []map[string]any `json:"items"`
|
||||||
|
} `json:"data"`
|
||||||
|
}
|
||||||
|
if err := c.getJSON(ctx, "/open-apis/drive/v1/files", query, &payload); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if payload.Code != 0 {
|
||||||
|
return nil, fmt.Errorf("feishu list drive files api error (code=%d msg=%s)", payload.Code, payload.Msg)
|
||||||
|
}
|
||||||
|
items := payload.Data.Files
|
||||||
|
if len(items) == 0 {
|
||||||
|
items = payload.Data.Items
|
||||||
|
}
|
||||||
|
result := &DriveFileList{HasMore: payload.Data.HasMore, PageToken: payload.Data.PageToken}
|
||||||
|
for _, item := range items {
|
||||||
|
result.Items = append(result.Items, normalizeDriveFile(item))
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *FeishuChannel) DownloadDriveFile(ctx context.Context, fileToken string) (*DownloadedFile, error) {
|
||||||
|
if strings.TrimSpace(fileToken) == "" {
|
||||||
|
return nil, fmt.Errorf("file token is empty")
|
||||||
|
}
|
||||||
|
resp, err := c.client.Do(ctx, &larkcore.ApiReq{HttpMethod: http.MethodGet, ApiPath: "/open-apis/drive/v1/files/" + url.PathEscape(fileToken) + "/download", SupportedAccessTokenTypes: []larkcore.AccessTokenType{larkcore.AccessTokenTypeTenant}})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
contentType := ""
|
||||||
|
if resp.Header != nil {
|
||||||
|
contentType = resp.Header.Get("Content-Type")
|
||||||
|
}
|
||||||
|
name := fileToken
|
||||||
|
if resp.Header != nil {
|
||||||
|
if headerName := filenameFromHeader(resp.Header); headerName != "" {
|
||||||
|
name = headerName
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return &DownloadedFile{Name: name, ContentType: contentType, Data: resp.RawBody}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *FeishuChannel) DeleteDriveFile(ctx context.Context, fileToken string) error {
|
||||||
|
if strings.TrimSpace(fileToken) == "" {
|
||||||
|
return fmt.Errorf("file token is empty")
|
||||||
|
}
|
||||||
|
var payload struct {
|
||||||
|
Code int `json:"code"`
|
||||||
|
Msg string `json:"msg"`
|
||||||
|
}
|
||||||
|
if err := c.doJSON(ctx, http.MethodDelete, "/open-apis/drive/v1/files/"+url.PathEscape(fileToken), nil, nil, &payload); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if payload.Code != 0 {
|
||||||
|
return fmt.Errorf("feishu delete drive file api error (code=%d msg=%s)", payload.Code, payload.Msg)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *FeishuChannel) UploadDriveFile(ctx context.Context, parentToken, name string, r io.Reader) (*DriveFileSummary, error) {
|
||||||
|
if strings.TrimSpace(name) == "" {
|
||||||
|
return nil, fmt.Errorf("file name is empty")
|
||||||
|
}
|
||||||
|
body, contentType, err := buildDriveUploadBody(parentToken, name, r)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var payload struct {
|
||||||
|
Code int `json:"code"`
|
||||||
|
Msg string `json:"msg"`
|
||||||
|
Data struct {
|
||||||
|
File map[string]any `json:"file"`
|
||||||
|
} `json:"data"`
|
||||||
|
}
|
||||||
|
if err := c.doMultipart(ctx, http.MethodPost, "/open-apis/drive/v1/files/upload_all", body.Bytes(), contentType, &payload); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if payload.Code != 0 {
|
||||||
|
return nil, fmt.Errorf("feishu upload drive file api error (code=%d msg=%s)", payload.Code, payload.Msg)
|
||||||
|
}
|
||||||
|
file := normalizeDriveFile(payload.Data.File)
|
||||||
|
return &file, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *FeishuChannel) InitiateMultipartUpload(ctx context.Context, parentToken, name string, size int64) (*MultipartUploadSession, error) {
|
||||||
|
body := map[string]any{"file_name": name, "size": size}
|
||||||
|
if parentToken != "" {
|
||||||
|
body["parent_type"] = "explorer"
|
||||||
|
body["parent_node"] = parentToken
|
||||||
|
}
|
||||||
|
var payload struct {
|
||||||
|
Code int `json:"code"`
|
||||||
|
Msg string `json:"msg"`
|
||||||
|
Data struct {
|
||||||
|
FileToken string `json:"file_token"`
|
||||||
|
UploadID string `json:"upload_id"`
|
||||||
|
BlockSize int `json:"block_size"`
|
||||||
|
} `json:"data"`
|
||||||
|
}
|
||||||
|
if err := c.postJSON(ctx, "/open-apis/drive/v1/files/upload_prepare", body, &payload); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if payload.Code != 0 {
|
||||||
|
return nil, fmt.Errorf("feishu initiate multipart upload api error (code=%d msg=%s)", payload.Code, payload.Msg)
|
||||||
|
}
|
||||||
|
return &MultipartUploadSession{FileToken: payload.Data.FileToken, UploadID: payload.Data.UploadID, BlockSize: payload.Data.BlockSize}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *FeishuChannel) UploadMultipartChunk(ctx context.Context, uploadID string, seq int, data []byte) error {
|
||||||
|
if strings.TrimSpace(uploadID) == "" {
|
||||||
|
return fmt.Errorf("upload ID is empty")
|
||||||
|
}
|
||||||
|
body, contentType, err := buildMultipartChunkBody(seq, data)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
path := "/open-apis/drive/v1/files/upload_part?upload_id=" + url.QueryEscape(uploadID)
|
||||||
|
var payload struct {
|
||||||
|
Code int `json:"code"`
|
||||||
|
Msg string `json:"msg"`
|
||||||
|
}
|
||||||
|
if err := c.doMultipart(ctx, http.MethodPost, path, body.Bytes(), contentType, &payload); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if payload.Code != 0 {
|
||||||
|
return fmt.Errorf("feishu upload multipart chunk api error (code=%d msg=%s)", payload.Code, payload.Msg)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *FeishuChannel) CompleteMultipartUpload(ctx context.Context, uploadID string, blockNum int) (*DriveFileSummary, error) {
|
||||||
|
body := map[string]any{"upload_id": uploadID, "block_num": blockNum}
|
||||||
|
var payload struct {
|
||||||
|
Code int `json:"code"`
|
||||||
|
Msg string `json:"msg"`
|
||||||
|
Data struct {
|
||||||
|
File map[string]any `json:"file"`
|
||||||
|
} `json:"data"`
|
||||||
|
}
|
||||||
|
if err := c.postJSON(ctx, "/open-apis/drive/v1/files/upload_finish", body, &payload); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if payload.Code != 0 {
|
||||||
|
return nil, fmt.Errorf("feishu complete multipart upload api error (code=%d msg=%s)", payload.Code, payload.Msg)
|
||||||
|
}
|
||||||
|
file := normalizeDriveFile(payload.Data.File)
|
||||||
|
return &file, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *FeishuChannel) getJSON(ctx context.Context, apiPath string, query map[string]string, out any) error {
|
||||||
|
return c.doJSON(ctx, http.MethodGet, apiPath, query, nil, out)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *FeishuChannel) postJSON(ctx context.Context, apiPath string, body any, out any) error {
|
||||||
|
return c.doJSON(ctx, http.MethodPost, apiPath, nil, body, out)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *FeishuChannel) doJSON(ctx context.Context, method, apiPath string, query map[string]string, body any, out any) error {
|
||||||
|
if ctx == nil {
|
||||||
|
ctx = context.Background()
|
||||||
|
}
|
||||||
|
if len(query) > 0 {
|
||||||
|
values := url.Values{}
|
||||||
|
for k, v := range query {
|
||||||
|
if v != "" {
|
||||||
|
values.Set(k, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if encoded := values.Encode(); encoded != "" {
|
||||||
|
if strings.Contains(apiPath, "?") {
|
||||||
|
apiPath += "&" + encoded
|
||||||
|
} else {
|
||||||
|
apiPath += "?" + encoded
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var payload []byte
|
||||||
|
var err error
|
||||||
|
if body != nil {
|
||||||
|
payload, err = json.Marshal(body)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
resp, err := c.client.Do(ctx, &larkcore.ApiReq{
|
||||||
|
HttpMethod: method,
|
||||||
|
ApiPath: apiPath,
|
||||||
|
Body: payload,
|
||||||
|
SupportedAccessTokenTypes: []larkcore.AccessTokenType{larkcore.AccessTokenTypeTenant},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if out == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return json.Unmarshal(resp.RawBody, out)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *FeishuChannel) doMultipart(ctx context.Context, method, apiPath string, body []byte, contentType string, out any) error {
|
||||||
|
_ = contentType
|
||||||
|
resp, err := c.client.Do(ctx, &larkcore.ApiReq{
|
||||||
|
HttpMethod: method,
|
||||||
|
ApiPath: apiPath,
|
||||||
|
Body: body,
|
||||||
|
SupportedAccessTokenTypes: []larkcore.AccessTokenType{larkcore.AccessTokenTypeTenant},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if out == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return json.Unmarshal(resp.RawBody, out)
|
||||||
|
}
|
||||||
|
|
||||||
|
func mustJSONMarshal(v any) []byte {
|
||||||
|
b, _ := json.Marshal(v)
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *FeishuChannel) downloadToPath(ctx context.Context, apiPath string) (*DownloadedFile, error) {
|
||||||
|
resp, err := c.client.Do(ctx, &larkcore.ApiReq{HttpMethod: http.MethodGet, ApiPath: apiPath, SupportedAccessTokenTypes: []larkcore.AccessTokenType{larkcore.AccessTokenTypeTenant}})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
name := "download.bin"
|
||||||
|
if resp.Header != nil {
|
||||||
|
if fileName := filenameFromHeader(resp.Header); fileName != "" {
|
||||||
|
name = fileName
|
||||||
|
}
|
||||||
|
}
|
||||||
|
contentType := ""
|
||||||
|
if resp.Header != nil {
|
||||||
|
contentType = resp.Header.Get("Content-Type")
|
||||||
|
}
|
||||||
|
return &DownloadedFile{Name: name, ContentType: contentType, Data: append([]byte(nil), resp.RawBody...)}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func bytesReader(data []byte) io.Reader { return bytes.NewReader(data) }
|
||||||
|
|
||||||
137
pkg/channels/feishu/api_types.go
Normal file
137
pkg/channels/feishu/api_types.go
Normal file
|
|
@ -0,0 +1,137 @@
|
||||||
|
package feishu
|
||||||
|
|
||||||
|
type ParsedMessage struct {
|
||||||
|
MessageID string `json:"message_id,omitempty"`
|
||||||
|
ChatID string `json:"chat_id,omitempty"`
|
||||||
|
MsgType string `json:"msg_type,omitempty"`
|
||||||
|
CreateTime string `json:"create_time,omitempty"`
|
||||||
|
Sender map[string]any `json:"sender,omitempty"`
|
||||||
|
Content any `json:"content,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CardComponent struct {
|
||||||
|
Index int `json:"index"`
|
||||||
|
Tag string `json:"tag,omitempty"`
|
||||||
|
Details map[string]any `json:"details,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CardSummary struct {
|
||||||
|
Title string `json:"title,omitempty"`
|
||||||
|
Components []CardComponent `json:"components,omitempty"`
|
||||||
|
ImageKeys []string `json:"image_keys,omitempty"`
|
||||||
|
TextContents []string `json:"text_contents,omitempty"`
|
||||||
|
ActionButtons []string `json:"action_buttons,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type MessageDetail struct {
|
||||||
|
MessageID string `json:"message_id,omitempty"`
|
||||||
|
ChatID string `json:"chat_id,omitempty"`
|
||||||
|
RootID string `json:"root_id,omitempty"`
|
||||||
|
ParentID string `json:"parent_id,omitempty"`
|
||||||
|
MsgType string `json:"msg_type,omitempty"`
|
||||||
|
Deleted bool `json:"deleted,omitempty"`
|
||||||
|
Updated bool `json:"updated,omitempty"`
|
||||||
|
CreateTime string `json:"create_time,omitempty"`
|
||||||
|
UpdateTime string `json:"update_time,omitempty"`
|
||||||
|
Sender map[string]any `json:"sender,omitempty"`
|
||||||
|
Body map[string]any `json:"body,omitempty"`
|
||||||
|
Mentions []map[string]any `json:"mentions,omitempty"`
|
||||||
|
Raw map[string]any `json:"raw,omitempty"`
|
||||||
|
Parsed *ParsedMessage `json:"parsed,omitempty"`
|
||||||
|
CardParsed *CardSummary `json:"card_parsed,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type MessageList struct {
|
||||||
|
Items []MessageDetail `json:"items,omitempty"`
|
||||||
|
HasMore bool `json:"has_more,omitempty"`
|
||||||
|
PageToken string `json:"page_token,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type UserSummary struct {
|
||||||
|
ID string `json:"id,omitempty"`
|
||||||
|
OpenID string `json:"open_id,omitempty"`
|
||||||
|
UserID string `json:"user_id,omitempty"`
|
||||||
|
UnionID string `json:"union_id,omitempty"`
|
||||||
|
Name string `json:"name,omitempty"`
|
||||||
|
EnName string `json:"en_name,omitempty"`
|
||||||
|
Email string `json:"email,omitempty"`
|
||||||
|
Mobile string `json:"mobile,omitempty"`
|
||||||
|
AvatarURL string `json:"avatar_url,omitempty"`
|
||||||
|
Status map[string]any `json:"status,omitempty"`
|
||||||
|
Raw map[string]any `json:"raw,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type UserList struct {
|
||||||
|
Items []UserSummary `json:"items,omitempty"`
|
||||||
|
HasMore bool `json:"has_more,omitempty"`
|
||||||
|
PageToken string `json:"page_token,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ChatSummary struct {
|
||||||
|
ChatID string `json:"chat_id,omitempty"`
|
||||||
|
Name string `json:"name,omitempty"`
|
||||||
|
Description string `json:"description,omitempty"`
|
||||||
|
ChatMode string `json:"chat_mode,omitempty"`
|
||||||
|
ChatType string `json:"chat_type,omitempty"`
|
||||||
|
OwnerID string `json:"owner_id,omitempty"`
|
||||||
|
OwnerOpenID string `json:"owner_open_id,omitempty"`
|
||||||
|
External bool `json:"external,omitempty"`
|
||||||
|
TenantKey string `json:"tenant_key,omitempty"`
|
||||||
|
Avatar string `json:"avatar,omitempty"`
|
||||||
|
Raw map[string]any `json:"raw,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ChatList struct {
|
||||||
|
Items []ChatSummary `json:"items,omitempty"`
|
||||||
|
HasMore bool `json:"has_more,omitempty"`
|
||||||
|
PageToken string `json:"page_token,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ShareLinkLookupResult struct {
|
||||||
|
Token string `json:"token,omitempty"`
|
||||||
|
DecodedMessageID string `json:"decoded_message_id,omitempty"`
|
||||||
|
Message *MessageDetail `json:"message,omitempty"`
|
||||||
|
FallbackError string `json:"fallback_error,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type DriveFileSummary struct {
|
||||||
|
FileToken string `json:"file_token,omitempty"`
|
||||||
|
Name string `json:"name,omitempty"`
|
||||||
|
Type string `json:"type,omitempty"`
|
||||||
|
ParentToken string `json:"parent_token,omitempty"`
|
||||||
|
Size int64 `json:"size,omitempty"`
|
||||||
|
Extension string `json:"extension,omitempty"`
|
||||||
|
MimeType string `json:"mime_type,omitempty"`
|
||||||
|
URL string `json:"url,omitempty"`
|
||||||
|
CreatedTime string `json:"created_time,omitempty"`
|
||||||
|
ModifiedTime string `json:"modified_time,omitempty"`
|
||||||
|
Raw map[string]any `json:"raw,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type DriveFolderSummary struct {
|
||||||
|
FolderToken string `json:"folder_token,omitempty"`
|
||||||
|
Name string `json:"name,omitempty"`
|
||||||
|
ParentToken string `json:"parent_token,omitempty"`
|
||||||
|
URL string `json:"url,omitempty"`
|
||||||
|
CreatedTime string `json:"created_time,omitempty"`
|
||||||
|
ModifiedTime string `json:"modified_time,omitempty"`
|
||||||
|
Raw map[string]any `json:"raw,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type DriveFileList struct {
|
||||||
|
Items []DriveFileSummary `json:"items,omitempty"`
|
||||||
|
HasMore bool `json:"has_more,omitempty"`
|
||||||
|
PageToken string `json:"page_token,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type MultipartUploadSession struct {
|
||||||
|
FileToken string `json:"file_token,omitempty"`
|
||||||
|
UploadID string `json:"upload_id,omitempty"`
|
||||||
|
BlockSize int `json:"block_size,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type DownloadedFile struct {
|
||||||
|
Name string `json:"name,omitempty"`
|
||||||
|
ContentType string `json:"content_type,omitempty"`
|
||||||
|
Data []byte `json:"data,omitempty"`
|
||||||
|
}
|
||||||
|
|
@ -43,32 +43,65 @@ func buildMarkdownCard(content string) (string, error) {
|
||||||
// extractJSONStringField unmarshals content as JSON and returns the value of the given string field.
|
// extractJSONStringField unmarshals content as JSON and returns the value of the given string field.
|
||||||
// Returns "" if the content is invalid JSON or the field is missing/empty.
|
// Returns "" if the content is invalid JSON or the field is missing/empty.
|
||||||
func extractJSONStringField(content, field string) string {
|
func extractJSONStringField(content, field string) string {
|
||||||
var m map[string]json.RawMessage
|
all := extractAllJSONStringFields(content, field)
|
||||||
if err := json.Unmarshal([]byte(content), &m); err != nil {
|
if len(all) == 0 {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
raw, ok := m[field]
|
return all[0]
|
||||||
if !ok {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
var s string
|
|
||||||
if err := json.Unmarshal(raw, &s); err != nil {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return s
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// extractImageKey extracts the image_key from a Feishu image message content JSON.
|
func extractAllJSONStringFields(content, field string) []string {
|
||||||
// Format: {"image_key": "img_xxx"}
|
var decoded any
|
||||||
|
if err := json.Unmarshal([]byte(content), &decoded); err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return collectJSONStringFields(decoded, field)
|
||||||
|
}
|
||||||
|
|
||||||
|
func collectJSONStringFields(v any, field string) []string {
|
||||||
|
seen := map[string]struct{}{}
|
||||||
|
result := make([]string, 0)
|
||||||
|
var walk func(any)
|
||||||
|
walk = func(node any) {
|
||||||
|
switch vv := node.(type) {
|
||||||
|
case map[string]any:
|
||||||
|
if value, ok := vv[field].(string); ok {
|
||||||
|
value = strings.TrimSpace(value)
|
||||||
|
if value != "" {
|
||||||
|
if _, exists := seen[value]; !exists {
|
||||||
|
seen[value] = struct{}{}
|
||||||
|
result = append(result, value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, nested := range vv {
|
||||||
|
walk(nested)
|
||||||
|
}
|
||||||
|
case []any:
|
||||||
|
for _, nested := range vv {
|
||||||
|
walk(nested)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
walk(v)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractImageKey extracts the first image_key from a Feishu image message content JSON.
|
||||||
func extractImageKey(content string) string { return extractJSONStringField(content, "image_key") }
|
func extractImageKey(content string) string { return extractJSONStringField(content, "image_key") }
|
||||||
|
|
||||||
// extractFileKey extracts the file_key from a Feishu file/audio message content JSON.
|
func extractImageKeys(content string) []string { return extractAllJSONStringFields(content, "image_key") }
|
||||||
// Format: {"file_key": "file_xxx", "file_name": "...", ...}
|
|
||||||
|
// extractFileKey extracts the first file_key from a Feishu file/audio message content JSON.
|
||||||
func extractFileKey(content string) string { return extractJSONStringField(content, "file_key") }
|
func extractFileKey(content string) string { return extractJSONStringField(content, "file_key") }
|
||||||
|
|
||||||
|
func extractFileKeys(content string) []string { return extractAllJSONStringFields(content, "file_key") }
|
||||||
|
|
||||||
// extractFileName extracts the file_name from a Feishu file message content JSON.
|
// extractFileName extracts the file_name from a Feishu file message content JSON.
|
||||||
func extractFileName(content string) string { return extractJSONStringField(content, "file_name") }
|
func extractFileName(content string) string { return extractJSONStringField(content, "file_name") }
|
||||||
|
|
||||||
|
func extractFileNames(content string) []string { return extractAllJSONStringFields(content, "file_name") }
|
||||||
|
|
||||||
// stripMentionPlaceholders removes @_user_N placeholders from the text content.
|
// stripMentionPlaceholders removes @_user_N placeholders from the text content.
|
||||||
// These are inserted by Feishu when users @mention someone in a message.
|
// These are inserted by Feishu when users @mention someone in a message.
|
||||||
func stripMentionPlaceholders(content string, mentions []*larkim.MentionEvent) string {
|
func stripMentionPlaceholders(content string, mentions []*larkim.MentionEvent) string {
|
||||||
|
|
|
||||||
110
pkg/channels/feishu/drive_helpers.go
Normal file
110
pkg/channels/feishu/drive_helpers.go
Normal file
|
|
@ -0,0 +1,110 @@
|
||||||
|
package feishu
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
type feishuMultipartDriveClient interface {
|
||||||
|
InitiateMultipartUpload(context.Context, string, string, int64) (*MultipartUploadSession, error)
|
||||||
|
UploadMultipartChunk(context.Context, string, int, []byte) error
|
||||||
|
CompleteMultipartUpload(context.Context, string, int) (*DriveFileSummary, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *FeishuChannel) UploadLargeDriveFile(ctx context.Context, parentToken, name string, r io.Reader, size int64) (*DriveFileSummary, error) {
|
||||||
|
if strings.TrimSpace(name) == "" {
|
||||||
|
return nil, fmt.Errorf("file name is empty")
|
||||||
|
}
|
||||||
|
if r == nil {
|
||||||
|
return nil, fmt.Errorf("file reader is nil")
|
||||||
|
}
|
||||||
|
if size <= 0 {
|
||||||
|
return nil, fmt.Errorf("file size must be > 0")
|
||||||
|
}
|
||||||
|
client, ok := any(c).(feishuMultipartDriveClient)
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("multipart drive upload is not supported in this build")
|
||||||
|
}
|
||||||
|
session, err := client.InitiateMultipartUpload(ctx, parentToken, name, size)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
blockSize := session.BlockSize
|
||||||
|
if blockSize <= 0 {
|
||||||
|
blockSize = 4 * 1024 * 1024
|
||||||
|
}
|
||||||
|
chunks, err := uploadMultipartFromReader(ctx, client, session.UploadID, r, blockSize)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if chunks == 0 {
|
||||||
|
return nil, fmt.Errorf("no multipart chunks uploaded")
|
||||||
|
}
|
||||||
|
return client.CompleteMultipartUpload(ctx, session.UploadID, chunks)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *FeishuChannel) UploadLargeDriveFileFromPath(ctx context.Context, parentToken, path string) (*DriveFileSummary, error) {
|
||||||
|
if strings.TrimSpace(path) == "" {
|
||||||
|
return nil, fmt.Errorf("file path is empty")
|
||||||
|
}
|
||||||
|
file, err := os.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
info, err := file.Stat()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if info.IsDir() {
|
||||||
|
return nil, fmt.Errorf("path is a directory: %s", path)
|
||||||
|
}
|
||||||
|
return c.UploadLargeDriveFile(ctx, parentToken, filepath.Base(path), file, info.Size())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *FeishuChannel) uploadMultipartFromReader(ctx context.Context, uploadID string, r io.Reader, blockSize int) (int, error) {
|
||||||
|
client, ok := any(c).(feishuMultipartDriveClient)
|
||||||
|
if !ok {
|
||||||
|
return 0, fmt.Errorf("multipart drive upload is not supported in this build")
|
||||||
|
}
|
||||||
|
return uploadMultipartFromReader(ctx, client, uploadID, r, blockSize)
|
||||||
|
}
|
||||||
|
|
||||||
|
func uploadMultipartFromReader(ctx context.Context, client feishuMultipartDriveClient, uploadID string, r io.Reader, blockSize int) (int, error) {
|
||||||
|
if strings.TrimSpace(uploadID) == "" {
|
||||||
|
return 0, fmt.Errorf("upload ID is empty")
|
||||||
|
}
|
||||||
|
if r == nil {
|
||||||
|
return 0, fmt.Errorf("file reader is nil")
|
||||||
|
}
|
||||||
|
if blockSize <= 0 {
|
||||||
|
return 0, fmt.Errorf("block size must be > 0")
|
||||||
|
}
|
||||||
|
buf := make([]byte, blockSize)
|
||||||
|
seq := 0
|
||||||
|
for {
|
||||||
|
n, err := io.ReadFull(r, buf)
|
||||||
|
if err == io.EOF {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if err != nil && err != io.ErrUnexpectedEOF {
|
||||||
|
return seq, err
|
||||||
|
}
|
||||||
|
if n <= 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
chunk := append([]byte(nil), buf[:n]...)
|
||||||
|
if err := client.UploadMultipartChunk(ctx, uploadID, seq, chunk); err != nil {
|
||||||
|
return seq, err
|
||||||
|
}
|
||||||
|
seq++
|
||||||
|
if err == io.ErrUnexpectedEOF {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return seq, nil
|
||||||
|
}
|
||||||
87
pkg/channels/feishu/drive_helpers_test.go
Normal file
87
pkg/channels/feishu/drive_helpers_test.go
Normal file
|
|
@ -0,0 +1,87 @@
|
||||||
|
package feishu
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestUploadMultipartFromReaderValidation(t *testing.T) {
|
||||||
|
ch := &FeishuChannel{}
|
||||||
|
if _, err := ch.uploadMultipartFromReader(context.Background(), "", bytes.NewReader([]byte("x")), 4); err == nil {
|
||||||
|
t.Fatal("expected error for empty upload id")
|
||||||
|
}
|
||||||
|
if _, err := ch.uploadMultipartFromReader(context.Background(), "upload_1", nil, 4); err == nil {
|
||||||
|
t.Fatal("expected error for nil reader")
|
||||||
|
}
|
||||||
|
if _, err := ch.uploadMultipartFromReader(context.Background(), "upload_1", bytes.NewReader([]byte("x")), 0); err == nil {
|
||||||
|
t.Fatal("expected error for invalid block size")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUploadMultipartReadPattern(t *testing.T) {
|
||||||
|
data := []byte("abcdefghij")
|
||||||
|
reader := bytes.NewReader(data)
|
||||||
|
buf := make([]byte, 4)
|
||||||
|
var chunks [][]byte
|
||||||
|
for {
|
||||||
|
n, err := io.ReadFull(reader, buf)
|
||||||
|
if err == io.EOF {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if err != nil && err != io.ErrUnexpectedEOF {
|
||||||
|
t.Fatalf("unexpected read error: %v", err)
|
||||||
|
}
|
||||||
|
if n <= 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
chunks = append(chunks, append([]byte(nil), buf[:n]...))
|
||||||
|
if err == io.ErrUnexpectedEOF {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(chunks) != 3 {
|
||||||
|
t.Fatalf("chunk count = %d, want 3", len(chunks))
|
||||||
|
}
|
||||||
|
if string(chunks[0]) != "abcd" || string(chunks[1]) != "efgh" || string(chunks[2]) != "ij" {
|
||||||
|
t.Fatalf("unexpected chunks: %q %q %q", chunks[0], chunks[1], chunks[2])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUploadLargeDriveFileValidation(t *testing.T) {
|
||||||
|
ch := &FeishuChannel{}
|
||||||
|
if _, err := ch.UploadLargeDriveFile(context.Background(), "", "", bytes.NewReader([]byte("x")), 1); err == nil {
|
||||||
|
t.Fatal("expected error for empty name")
|
||||||
|
}
|
||||||
|
if _, err := ch.UploadLargeDriveFile(context.Background(), "", "demo.bin", nil, 1); err == nil {
|
||||||
|
t.Fatal("expected error for nil reader")
|
||||||
|
}
|
||||||
|
if _, err := ch.UploadLargeDriveFile(context.Background(), "", "demo.bin", bytes.NewReader([]byte("x")), 0); err == nil {
|
||||||
|
t.Fatal("expected error for invalid size")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUploadLargeDriveFileFromPathValidation(t *testing.T) {
|
||||||
|
ch := &FeishuChannel{}
|
||||||
|
if _, err := ch.UploadLargeDriveFileFromPath(context.Background(), "", ""); err == nil {
|
||||||
|
t.Fatal("expected error for empty path")
|
||||||
|
}
|
||||||
|
dir := t.TempDir()
|
||||||
|
if _, err := ch.UploadLargeDriveFileFromPath(context.Background(), "", dir); err == nil {
|
||||||
|
t.Fatal("expected error for directory path")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUploadLargeDriveFileFromPathMissingFile(t *testing.T) {
|
||||||
|
ch := &FeishuChannel{}
|
||||||
|
_, err := ch.UploadLargeDriveFileFromPath(context.Background(), "", "missing.bin")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for missing file")
|
||||||
|
}
|
||||||
|
if !errors.Is(err, os.ErrNotExist) {
|
||||||
|
t.Fatalf("expected not exist error, got: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
//go:build amd64 || arm64 || riscv64 || mips64 || ppc64
|
//go:build amd64 || arm64 || riscv64 || mips64 || ppc64
|
||||||
|
|
||||||
package feishu
|
package feishu
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
|
@ -11,9 +10,9 @@ import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
|
|
||||||
lark "github.com/larksuite/oapi-sdk-go/v3"
|
lark "github.com/larksuite/oapi-sdk-go/v3"
|
||||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||||
larkdispatcher "github.com/larksuite/oapi-sdk-go/v3/event/dispatcher"
|
larkdispatcher "github.com/larksuite/oapi-sdk-go/v3/event/dispatcher"
|
||||||
|
|
@ -282,32 +281,67 @@ func (c *FeishuChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMess
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// sendMediaPart resolves and sends a single media part.
|
func (c *FeishuChannel) SendImageMessage(ctx context.Context, chatID string, data []byte, fileName string) error {
|
||||||
func (c *FeishuChannel) sendMediaPart(
|
if !c.IsRunning() {
|
||||||
ctx context.Context,
|
return channels.ErrNotRunning
|
||||||
chatID string,
|
|
||||||
part bus.MediaPart,
|
|
||||||
store media.MediaStore,
|
|
||||||
) error {
|
|
||||||
localPath, err := store.Resolve(part.Ref)
|
|
||||||
if err != nil {
|
|
||||||
logger.ErrorCF("feishu", "Failed to resolve media ref", map[string]any{
|
|
||||||
"ref": part.Ref,
|
|
||||||
"error": err.Error(),
|
|
||||||
})
|
|
||||||
return nil // skip this part
|
|
||||||
}
|
}
|
||||||
|
if strings.TrimSpace(chatID) == "" {
|
||||||
file, err := os.Open(localPath)
|
return fmt.Errorf("chat ID is empty: %w", channels.ErrSendFailed)
|
||||||
|
}
|
||||||
|
if len(data) == 0 {
|
||||||
|
return fmt.Errorf("image data is empty: %w", channels.ErrSendFailed)
|
||||||
|
}
|
||||||
|
path, err := writeTempFeishuMediaFile(data, fileName, ".img")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.ErrorCF("feishu", "Failed to open media file", map[string]any{
|
return fmt.Errorf("feishu image temp file: %w", err)
|
||||||
"path": localPath,
|
}
|
||||||
"error": err.Error(),
|
defer os.Remove(path)
|
||||||
})
|
file, err := os.Open(path)
|
||||||
return nil // skip this part
|
if err != nil {
|
||||||
|
return fmt.Errorf("feishu image open temp file: %w", err)
|
||||||
}
|
}
|
||||||
defer file.Close()
|
defer file.Close()
|
||||||
|
return c.sendImage(ctx, chatID, file)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *FeishuChannel) SendFileMessage(ctx context.Context, chatID string, data []byte, fileName, fileType string) error {
|
||||||
|
if !c.IsRunning() {
|
||||||
|
return channels.ErrNotRunning
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(chatID) == "" {
|
||||||
|
return fmt.Errorf("chat ID is empty: %w", channels.ErrSendFailed)
|
||||||
|
}
|
||||||
|
if len(data) == 0 {
|
||||||
|
return fmt.Errorf("file data is empty: %w", channels.ErrSendFailed)
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(fileName) == "" {
|
||||||
|
fileName = "file"
|
||||||
|
}
|
||||||
|
path, err := writeTempFeishuMediaFile(data, fileName, ".bin")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("feishu file temp file: %w", err)
|
||||||
|
}
|
||||||
|
defer os.Remove(path)
|
||||||
|
file, err := os.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("feishu file open temp file: %w", err)
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
return c.sendFile(ctx, chatID, file, fileName, fileType)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *FeishuChannel) sendMediaPart(ctx context.Context, chatID string, part bus.MediaPart, store media.MediaStore) error {
|
||||||
|
localPath, err := store.Resolve(part.Ref)
|
||||||
|
if err != nil {
|
||||||
|
logger.ErrorCF("feishu", "Failed to resolve media ref", map[string]any{"ref": part.Ref, "error": err.Error()})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
file, err := os.Open(localPath)
|
||||||
|
if err != nil {
|
||||||
|
logger.ErrorCF("feishu", "Failed to open media file", map[string]any{"path": localPath, "error": err.Error()})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
switch part.Type {
|
switch part.Type {
|
||||||
case "image":
|
case "image":
|
||||||
err = c.sendImage(ctx, chatID, file)
|
err = c.sendImage(ctx, chatID, file)
|
||||||
|
|
@ -318,17 +352,40 @@ func (c *FeishuChannel) sendMediaPart(
|
||||||
}
|
}
|
||||||
err = c.sendFile(ctx, chatID, file, filename, part.Type)
|
err = c.sendFile(ctx, chatID, file, filename, part.Type)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.ErrorCF("feishu", "Failed to send media", map[string]any{
|
logger.ErrorCF("feishu", "Failed to send media", map[string]any{"type": part.Type, "error": err.Error()})
|
||||||
"type": part.Type,
|
|
||||||
"error": err.Error(),
|
|
||||||
})
|
|
||||||
return fmt.Errorf("feishu send media: %w", channels.ErrTemporary)
|
return fmt.Errorf("feishu send media: %w", channels.ErrTemporary)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func writeTempFeishuMediaFile(data []byte, fileName, fallbackExt string) (string, error) {
|
||||||
|
name := strings.TrimSpace(fileName)
|
||||||
|
if name == "" {
|
||||||
|
name = "media"
|
||||||
|
}
|
||||||
|
ext := filepath.Ext(name)
|
||||||
|
if ext == "" {
|
||||||
|
ext = fallbackExt
|
||||||
|
}
|
||||||
|
pattern := "feishu-media-*" + ext
|
||||||
|
f, err := os.CreateTemp("", pattern)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
path := f.Name()
|
||||||
|
if _, err := f.Write(data); err != nil {
|
||||||
|
f.Close()
|
||||||
|
os.Remove(path)
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if err := f.Close(); err != nil {
|
||||||
|
os.Remove(path)
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return path, nil
|
||||||
|
}
|
||||||
|
|
||||||
// --- Inbound message handling ---
|
// --- Inbound message handling ---
|
||||||
|
|
||||||
func (c *FeishuChannel) handleMessageReceive(ctx context.Context, event *larkim.P2MessageReceiveV1) error {
|
func (c *FeishuChannel) handleMessageReceive(ctx context.Context, event *larkim.P2MessageReceiveV1) error {
|
||||||
|
|
@ -504,15 +561,16 @@ func extractContent(messageType, rawContent string) string {
|
||||||
return rawContent
|
return rawContent
|
||||||
|
|
||||||
case larkim.MsgTypePost:
|
case larkim.MsgTypePost:
|
||||||
// Pass raw JSON to LLM — structured rich text is more informative than flattened plain text
|
texts := extractAllJSONStringFields(rawContent, "text")
|
||||||
|
if len(texts) > 0 {
|
||||||
|
return strings.TrimSpace(strings.Join(texts, "\n"))
|
||||||
|
}
|
||||||
return rawContent
|
return rawContent
|
||||||
|
|
||||||
case larkim.MsgTypeImage:
|
case larkim.MsgTypeImage:
|
||||||
// Image messages don't have text content
|
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
case larkim.MsgTypeFile, larkim.MsgTypeAudio, larkim.MsgTypeMedia:
|
case larkim.MsgTypeFile, larkim.MsgTypeAudio, larkim.MsgTypeMedia:
|
||||||
// File/audio/video messages may have a filename
|
|
||||||
name := extractFileName(rawContent)
|
name := extractFileName(rawContent)
|
||||||
if name != "" {
|
if name != "" {
|
||||||
return name
|
return name
|
||||||
|
|
@ -535,21 +593,28 @@ func (c *FeishuChannel) downloadInboundMedia(
|
||||||
|
|
||||||
switch messageType {
|
switch messageType {
|
||||||
case larkim.MsgTypeImage:
|
case larkim.MsgTypeImage:
|
||||||
imageKey := extractImageKey(rawContent)
|
for _, imageKey := range extractImageKeys(rawContent) {
|
||||||
if imageKey == "" {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
ref := c.downloadResource(ctx, messageID, imageKey, "image", ".jpg", store, scope)
|
ref := c.downloadResource(ctx, messageID, imageKey, "image", ".jpg", store, scope)
|
||||||
if ref != "" {
|
if ref != "" {
|
||||||
refs = append(refs, ref)
|
refs = append(refs, ref)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
case larkim.MsgTypePost:
|
||||||
|
for _, imageKey := range extractImageKeys(rawContent) {
|
||||||
|
ref := c.downloadResource(ctx, messageID, imageKey, "image", ".jpg", store, scope)
|
||||||
|
if ref != "" {
|
||||||
|
refs = append(refs, ref)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, fileKey := range extractFileKeys(rawContent) {
|
||||||
|
ref := c.downloadResource(ctx, messageID, fileKey, "file", "", store, scope)
|
||||||
|
if ref != "" {
|
||||||
|
refs = append(refs, ref)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
case larkim.MsgTypeFile, larkim.MsgTypeAudio, larkim.MsgTypeMedia:
|
case larkim.MsgTypeFile, larkim.MsgTypeAudio, larkim.MsgTypeMedia:
|
||||||
fileKey := extractFileKey(rawContent)
|
|
||||||
if fileKey == "" {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
// Derive a fallback extension from the message type.
|
|
||||||
var ext string
|
var ext string
|
||||||
switch messageType {
|
switch messageType {
|
||||||
case larkim.MsgTypeAudio:
|
case larkim.MsgTypeAudio:
|
||||||
|
|
@ -557,13 +622,15 @@ func (c *FeishuChannel) downloadInboundMedia(
|
||||||
case larkim.MsgTypeMedia:
|
case larkim.MsgTypeMedia:
|
||||||
ext = ".mp4"
|
ext = ".mp4"
|
||||||
default:
|
default:
|
||||||
ext = "" // generic file — rely on resp.FileName
|
ext = ""
|
||||||
}
|
}
|
||||||
|
for _, fileKey := range extractFileKeys(rawContent) {
|
||||||
ref := c.downloadResource(ctx, messageID, fileKey, "file", ext, store, scope)
|
ref := c.downloadResource(ctx, messageID, fileKey, "file", ext, store, scope)
|
||||||
if ref != "" {
|
if ref != "" {
|
||||||
refs = append(refs, ref)
|
refs = append(refs, ref)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return refs
|
return refs
|
||||||
}
|
}
|
||||||
|
|
@ -813,6 +880,7 @@ func (c *FeishuChannel) sendFile(ctx context.Context, chatID string, file *os.Fi
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
func extractFeishuSenderID(sender *larkim.EventSender) string {
|
func extractFeishuSenderID(sender *larkim.EventSender) string {
|
||||||
if sender == nil || sender.SenderId == nil {
|
if sender == nil || sender.SenderId == nil {
|
||||||
return ""
|
return ""
|
||||||
|
|
|
||||||
|
|
@ -3,9 +3,11 @@
|
||||||
package feishu
|
package feishu
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
larkim "github.com/larksuite/oapi-sdk-go/v3/service/im/v1"
|
larkim "github.com/larksuite/oapi-sdk-go/v3/service/im/v1"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/channels"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestExtractContent(t *testing.T) {
|
func TestExtractContent(t *testing.T) {
|
||||||
|
|
@ -254,3 +256,57 @@ func TestExtractFeishuSenderID(t *testing.T) {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSendImageMessageValidation(t *testing.T) {
|
||||||
|
base := channels.NewBaseChannel("feishu", nil, nil, nil)
|
||||||
|
ch := &FeishuChannel{BaseChannel: base}
|
||||||
|
if err := ch.SendImageMessage(t.Context(), "oc_1", []byte("x"), "demo.png"); !errors.Is(err, channels.ErrNotRunning) {
|
||||||
|
t.Fatalf("expected ErrNotRunning, got: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
base = channels.NewBaseChannel("feishu", nil, nil, nil)
|
||||||
|
ch = &FeishuChannel{BaseChannel: base}
|
||||||
|
ch.SetRunning(true)
|
||||||
|
if err := ch.SendImageMessage(t.Context(), "", []byte("x"), "demo.png"); !errors.Is(err, channels.ErrSendFailed) {
|
||||||
|
t.Fatalf("expected ErrSendFailed for empty chat ID, got: %v", err)
|
||||||
|
}
|
||||||
|
if err := ch.SendImageMessage(t.Context(), "oc_1", nil, "demo.png"); !errors.Is(err, channels.ErrSendFailed) {
|
||||||
|
t.Fatalf("expected ErrSendFailed for empty image data, got: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSendFileMessageValidation(t *testing.T) {
|
||||||
|
base := channels.NewBaseChannel("feishu", nil, nil, nil)
|
||||||
|
ch := &FeishuChannel{BaseChannel: base}
|
||||||
|
if err := ch.SendFileMessage(t.Context(), "oc_1", []byte("x"), "demo.txt", "stream"); !errors.Is(err, channels.ErrNotRunning) {
|
||||||
|
t.Fatalf("expected ErrNotRunning, got: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
base = channels.NewBaseChannel("feishu", nil, nil, nil)
|
||||||
|
ch = &FeishuChannel{BaseChannel: base}
|
||||||
|
ch.SetRunning(true)
|
||||||
|
if err := ch.SendFileMessage(t.Context(), "", []byte("x"), "demo.txt", "stream"); !errors.Is(err, channels.ErrSendFailed) {
|
||||||
|
t.Fatalf("expected ErrSendFailed for empty chat ID, got: %v", err)
|
||||||
|
}
|
||||||
|
if err := ch.SendFileMessage(t.Context(), "oc_1", nil, "demo.txt", "stream"); !errors.Is(err, channels.ErrSendFailed) {
|
||||||
|
t.Fatalf("expected ErrSendFailed for empty file data, got: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtractAllMediaKeys(t *testing.T) {
|
||||||
|
imagePayload := `{"title":"","content":[[{"tag":"text","text":"你看到了什么","style":[]}],[{"tag":"img","image_key":"img_1","width":800,"height":800},{"tag":"img","image_key":"img_2"}],[{"tag":"img","image_key":"img_1"}]]}`
|
||||||
|
images := extractImageKeys(imagePayload)
|
||||||
|
if len(images) != 2 || images[0] != "img_1" || images[1] != "img_2" {
|
||||||
|
t.Fatalf("unexpected image keys: %#v", images)
|
||||||
|
}
|
||||||
|
|
||||||
|
filePayload := `{"content":[[{"tag":"file","file_key":"file_1","file_name":"a.pdf"}],[{"tag":"file","file_key":"file_2","file_name":"b.pdf"}],[{"tag":"file","file_key":"file_1","file_name":"a.pdf"}]]}`
|
||||||
|
files := extractFileKeys(filePayload)
|
||||||
|
if len(files) != 2 || files[0] != "file_1" || files[1] != "file_2" {
|
||||||
|
t.Fatalf("unexpected file keys: %#v", files)
|
||||||
|
}
|
||||||
|
fileNames := extractFileNames(filePayload)
|
||||||
|
if len(fileNames) != 2 || fileNames[0] != "a.pdf" || fileNames[1] != "b.pdf" {
|
||||||
|
t.Fatalf("unexpected file names: %#v", fileNames)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
43
pkg/channels/feishu/integration_test.go
Normal file
43
pkg/channels/feishu/integration_test.go
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
//go:build integration
|
||||||
|
|
||||||
|
package feishu
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/bus"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
func newIntegrationChannel(t *testing.T) *FeishuChannel {
|
||||||
|
t.Helper()
|
||||||
|
cfg := config.FeishuConfig{
|
||||||
|
Enabled: true,
|
||||||
|
AppID: os.Getenv("FEISHU_APP_ID"),
|
||||||
|
AppSecret: os.Getenv("FEISHU_APP_SECRET"),
|
||||||
|
}
|
||||||
|
if cfg.AppID == "" || cfg.AppSecret == "" {
|
||||||
|
t.Skip("FEISHU_APP_ID or FEISHU_APP_SECRET is not set")
|
||||||
|
}
|
||||||
|
ch, err := NewFeishuChannel(cfg, bus.NewMessageBus())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("new channel: %v", err)
|
||||||
|
}
|
||||||
|
return ch
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIntegrationGetMessageRequiresMessageID(t *testing.T) {
|
||||||
|
ch := newIntegrationChannel(t)
|
||||||
|
if _, err := ch.GetMessage(context.Background(), ""); err == nil {
|
||||||
|
t.Fatal("expected error for empty message id")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIntegrationDriveRequiresFileToken(t *testing.T) {
|
||||||
|
ch := newIntegrationChannel(t)
|
||||||
|
if _, err := ch.GetDriveFile(context.Background(), ""); err == nil {
|
||||||
|
t.Fatal("expected error for empty file token")
|
||||||
|
}
|
||||||
|
}
|
||||||
3
pkg/channels/feishu/runtime_64_shared.go
Normal file
3
pkg/channels/feishu/runtime_64_shared.go
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
//go:build amd64 || arm64 || riscv64 || mips64 || ppc64
|
||||||
|
|
||||||
|
package feishu
|
||||||
|
|
@ -208,6 +208,24 @@ func (m *Manager) initChannel(name, displayName string) {
|
||||||
|
|
||||||
func (m *Manager) initChannels() error {
|
func (m *Manager) initChannels() error {
|
||||||
logger.InfoC("channels", "Initializing channel manager")
|
logger.InfoC("channels", "Initializing channel manager")
|
||||||
|
logger.InfoCF("channels", "Channel enablement snapshot", map[string]any{
|
||||||
|
"telegram_enabled": m.config.Channels.Telegram.Enabled,
|
||||||
|
"whatsapp_enabled": m.config.Channels.WhatsApp.Enabled,
|
||||||
|
"feishu_enabled": m.config.Channels.Feishu.Enabled,
|
||||||
|
"discord_enabled": m.config.Channels.Discord.Enabled,
|
||||||
|
"maixcam_enabled": m.config.Channels.MaixCam.Enabled,
|
||||||
|
"qq_enabled": m.config.Channels.QQ.Enabled,
|
||||||
|
"dingtalk_enabled": m.config.Channels.DingTalk.Enabled,
|
||||||
|
"slack_enabled": m.config.Channels.Slack.Enabled,
|
||||||
|
"matrix_enabled": m.config.Channels.Matrix.Enabled,
|
||||||
|
"line_enabled": m.config.Channels.LINE.Enabled,
|
||||||
|
"onebot_enabled": m.config.Channels.OneBot.Enabled,
|
||||||
|
"wecom_enabled": m.config.Channels.WeCom.Enabled,
|
||||||
|
"wecom_app_enabled": m.config.Channels.WeComApp.Enabled,
|
||||||
|
"wecom_aibot_enabled": m.config.Channels.WeComAIBot.Enabled,
|
||||||
|
"pico_enabled": m.config.Channels.Pico.Enabled,
|
||||||
|
"irc_enabled": m.config.Channels.IRC.Enabled,
|
||||||
|
})
|
||||||
|
|
||||||
if m.config.Channels.Telegram.Enabled && m.config.Channels.Telegram.Token != "" {
|
if m.config.Channels.Telegram.Enabled && m.config.Channels.Telegram.Token != "" {
|
||||||
m.initChannel("telegram", "Telegram")
|
m.initChannel("telegram", "Telegram")
|
||||||
|
|
@ -284,7 +302,17 @@ func (m *Manager) initChannels() error {
|
||||||
logger.InfoCF("channels", "Channel initialization completed", map[string]any{
|
logger.InfoCF("channels", "Channel initialization completed", map[string]any{
|
||||||
"enabled_channels": len(m.channels),
|
"enabled_channels": len(m.channels),
|
||||||
})
|
})
|
||||||
|
if len(m.channels) == 0 {
|
||||||
|
logger.WarnCF("channels", "No channels enabled after initialization", map[string]any{
|
||||||
|
"telegram_ready": m.config.Channels.Telegram.Enabled && m.config.Channels.Telegram.Token != "",
|
||||||
|
"whatsapp_ready": m.config.Channels.WhatsApp.Enabled && (m.config.Channels.WhatsApp.UseNative || m.config.Channels.WhatsApp.BridgeURL != ""),
|
||||||
|
"feishu_ready": m.config.Channels.Feishu.Enabled,
|
||||||
|
"discord_ready": m.config.Channels.Discord.Enabled && m.config.Channels.Discord.Token != "",
|
||||||
|
"matrix_ready": m.config.Channels.Matrix.Enabled && m.config.Channels.Matrix.Homeserver != "" && m.config.Channels.Matrix.UserID != "" && m.config.Channels.Matrix.AccessToken != "",
|
||||||
|
"line_ready": m.config.Channels.LINE.Enabled && m.config.Channels.LINE.ChannelAccessToken != "",
|
||||||
|
})
|
||||||
|
return fmt.Errorf("no channels enabled")
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -765,8 +793,8 @@ func (m *Manager) runTTLJanitor(ctx context.Context) {
|
||||||
func (m *Manager) GetChannel(name string) (Channel, bool) {
|
func (m *Manager) GetChannel(name string) (Channel, bool) {
|
||||||
m.mu.RLock()
|
m.mu.RLock()
|
||||||
defer m.mu.RUnlock()
|
defer m.mu.RUnlock()
|
||||||
channel, ok := m.channels[name]
|
ch, ok := m.channels[name]
|
||||||
return channel, ok
|
return ch, ok
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Manager) GetStatus() map[string]any {
|
func (m *Manager) GetStatus() map[string]any {
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import (
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
@ -401,20 +402,60 @@ func TestCodexCliProvider_GetDefaultModel(t *testing.T) {
|
||||||
func createMockCodexCLI(t *testing.T, events []string) string {
|
func createMockCodexCLI(t *testing.T, events []string) string {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
tmpDir := t.TempDir()
|
tmpDir := t.TempDir()
|
||||||
scriptPath := filepath.Join(tmpDir, "codex")
|
|
||||||
|
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
scriptPath := filepath.Join(tmpDir, "codex.cmd")
|
||||||
|
var sb strings.Builder
|
||||||
|
sb.WriteString("@echo off\r\n")
|
||||||
|
for _, event := range events {
|
||||||
|
escaped := strings.ReplaceAll(event, `"`, `""`)
|
||||||
|
sb.WriteString("echo " + escaped + "\r\n")
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(scriptPath, []byte(sb.String()), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return scriptPath
|
||||||
|
}
|
||||||
|
|
||||||
|
scriptPath := filepath.Join(tmpDir, "codex")
|
||||||
var sb strings.Builder
|
var sb strings.Builder
|
||||||
sb.WriteString("#!/bin/bash\n")
|
sb.WriteString("#!/bin/bash\n")
|
||||||
for _, event := range events {
|
for _, event := range events {
|
||||||
sb.WriteString(fmt.Sprintf("echo '%s'\n", event))
|
sb.WriteString(fmt.Sprintf("echo '%s'\n", event))
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := os.WriteFile(scriptPath, []byte(sb.String()), 0o755); err != nil {
|
if err := os.WriteFile(scriptPath, []byte(sb.String()), 0o755); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
return scriptPath
|
return scriptPath
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func createMockCodexCLIWithArgsCapture(t *testing.T, body string) (string, string) {
|
||||||
|
t.Helper()
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
argsPath := filepath.Join(tmpDir, "args.txt")
|
||||||
|
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
scriptPath := filepath.Join(tmpDir, "codex.cmd")
|
||||||
|
script := "@echo off\r\n" +
|
||||||
|
"setlocal EnableDelayedExpansion\r\n" +
|
||||||
|
"echo %* > \"" + argsPath + "\"\r\n" +
|
||||||
|
body + "\r\n"
|
||||||
|
if err := os.WriteFile(scriptPath, []byte(script), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return scriptPath, argsPath
|
||||||
|
}
|
||||||
|
|
||||||
|
scriptPath := filepath.Join(tmpDir, "codex")
|
||||||
|
script := "#!/bin/bash\n" +
|
||||||
|
"echo \"$@\" > \"" + argsPath + "\"\n" +
|
||||||
|
body + "\n"
|
||||||
|
if err := os.WriteFile(scriptPath, []byte(script), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return scriptPath, argsPath
|
||||||
|
}
|
||||||
|
|
||||||
func TestCodexCliProvider_MockCLI_Success(t *testing.T) {
|
func TestCodexCliProvider_MockCLI_Success(t *testing.T) {
|
||||||
scriptPath := createMockCodexCLI(t, []string{
|
scriptPath := createMockCodexCLI(t, []string{
|
||||||
`{"type":"thread.started","thread_id":"test-123"}`,
|
`{"type":"thread.started","thread_id":"test-123"}`,
|
||||||
|
|
@ -472,17 +513,10 @@ func TestCodexCliProvider_MockCLI_Error(t *testing.T) {
|
||||||
|
|
||||||
func TestCodexCliProvider_MockCLI_WithModel(t *testing.T) {
|
func TestCodexCliProvider_MockCLI_WithModel(t *testing.T) {
|
||||||
// Mock script that captures args to verify model flag is passed
|
// Mock script that captures args to verify model flag is passed
|
||||||
tmpDir := t.TempDir()
|
scriptPath, argsPath := createMockCodexCLIWithArgsCapture(t,
|
||||||
scriptPath := filepath.Join(tmpDir, "codex")
|
"echo '{\"type\":\"item.completed\",\"item\":{\"id\":\"1\",\"type\":\"agent_message\",\"text\":\"ok\"}}'\n"+
|
||||||
script := `#!/bin/bash
|
"echo '{\"type\":\"turn.completed\"}'",
|
||||||
# Write args to a file for verification
|
)
|
||||||
echo "$@" > "` + filepath.Join(tmpDir, "args.txt") + `"
|
|
||||||
echo '{"type":"item.completed","item":{"id":"1","type":"agent_message","text":"ok"}}'
|
|
||||||
echo '{"type":"turn.completed"}'`
|
|
||||||
|
|
||||||
if err := os.WriteFile(scriptPath, []byte(script), 0o755); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
p := &CodexCliProvider{
|
p := &CodexCliProvider{
|
||||||
command: scriptPath,
|
command: scriptPath,
|
||||||
|
|
@ -496,7 +530,7 @@ echo '{"type":"turn.completed"}'`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify the args
|
// Verify the args
|
||||||
argsData, err := os.ReadFile(filepath.Join(tmpDir, "args.txt"))
|
argsData, err := os.ReadFile(argsPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("reading args: %v", err)
|
t.Fatalf("reading args: %v", err)
|
||||||
}
|
}
|
||||||
|
|
@ -505,22 +539,33 @@ echo '{"type":"turn.completed"}'`
|
||||||
if !strings.Contains(args, "-m gpt-5.2-codex") {
|
if !strings.Contains(args, "-m gpt-5.2-codex") {
|
||||||
t.Errorf("args should contain model flag, got: %s", args)
|
t.Errorf("args should contain model flag, got: %s", args)
|
||||||
}
|
}
|
||||||
if !strings.Contains(args, "-C /tmp/test-workspace") {
|
|
||||||
t.Errorf("args should contain workspace flag, got: %s", args)
|
|
||||||
}
|
|
||||||
if !strings.Contains(args, "--json") {
|
if !strings.Contains(args, "--json") {
|
||||||
t.Errorf("args should contain --json, got: %s", args)
|
t.Errorf("args should contain --json, got: %s", args)
|
||||||
}
|
}
|
||||||
if !strings.Contains(args, "--dangerously-bypass-approvals-and-sandbox") {
|
if !strings.Contains(args, "--dangerously-bypass-approvals-and-sandbox") {
|
||||||
t.Errorf("args should contain bypass flag, got: %s", args)
|
t.Errorf("args should contain bypass flag, got: %s", args)
|
||||||
}
|
}
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
if !strings.Contains(args, `-C /tmp/test-workspace`) {
|
||||||
|
t.Errorf("args should contain workspace flag, got: %s", args)
|
||||||
|
}
|
||||||
|
} else if !strings.Contains(args, "-C /tmp/test-workspace") {
|
||||||
|
t.Errorf("args should contain workspace flag, got: %s", args)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCodexCliProvider_MockCLI_ContextCancel(t *testing.T) {
|
func TestCodexCliProvider_MockCLI_ContextCancel(t *testing.T) {
|
||||||
// Script that sleeps forever
|
// Script that sleeps forever
|
||||||
tmpDir := t.TempDir()
|
tmpDir := t.TempDir()
|
||||||
scriptPath := filepath.Join(tmpDir, "codex")
|
var scriptPath string
|
||||||
script := "#!/bin/bash\nsleep 60"
|
var script string
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
scriptPath = filepath.Join(tmpDir, "codex.cmd")
|
||||||
|
script = "@echo off\r\nping 127.0.0.1 -n 60 > nul\r\n"
|
||||||
|
} else {
|
||||||
|
scriptPath = filepath.Join(tmpDir, "codex")
|
||||||
|
script = "#!/bin/bash\nsleep 60"
|
||||||
|
}
|
||||||
|
|
||||||
if err := os.WriteFile(scriptPath, []byte(script), 0o755); err != nil {
|
if err := os.WriteFile(scriptPath, []byte(script), 0o755); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
|
|
|
||||||
|
|
@ -95,7 +95,7 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err
|
||||||
case "litellm", "openrouter", "groq", "zhipu", "gemini", "nvidia",
|
case "litellm", "openrouter", "groq", "zhipu", "gemini", "nvidia",
|
||||||
"ollama", "moonshot", "shengsuanyun", "deepseek", "cerebras",
|
"ollama", "moonshot", "shengsuanyun", "deepseek", "cerebras",
|
||||||
"vivgrid", "volcengine", "vllm", "qwen", "mistral", "avian",
|
"vivgrid", "volcengine", "vllm", "qwen", "mistral", "avian",
|
||||||
"minimax":
|
"minimax", "siliconflow":
|
||||||
// All other OpenAI-compatible HTTP providers
|
// All other OpenAI-compatible HTTP providers
|
||||||
if cfg.APIKey == "" && cfg.APIBase == "" {
|
if cfg.APIKey == "" && cfg.APIBase == "" {
|
||||||
return nil, "", fmt.Errorf("api_key or api_base is required for HTTP-based protocol %q", protocol)
|
return nil, "", fmt.Errorf("api_key or api_base is required for HTTP-based protocol %q", protocol)
|
||||||
|
|
@ -170,6 +170,15 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err
|
||||||
return provider, modelID, nil
|
return provider, modelID, nil
|
||||||
|
|
||||||
default:
|
default:
|
||||||
|
if strings.TrimSpace(cfg.APIBase) != "" {
|
||||||
|
return NewHTTPProviderWithMaxTokensFieldAndRequestTimeout(
|
||||||
|
cfg.APIKey,
|
||||||
|
cfg.APIBase,
|
||||||
|
cfg.Proxy,
|
||||||
|
cfg.MaxTokensField,
|
||||||
|
cfg.RequestTimeout,
|
||||||
|
), cfg.Model, nil
|
||||||
|
}
|
||||||
return nil, "", fmt.Errorf("unknown protocol %q in model %q", protocol, cfg.Model)
|
return nil, "", fmt.Errorf("unknown protocol %q in model %q", protocol, cfg.Model)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -215,6 +224,8 @@ func getDefaultAPIBase(protocol string) string {
|
||||||
return "https://api.avian.io/v1"
|
return "https://api.avian.io/v1"
|
||||||
case "minimax":
|
case "minimax":
|
||||||
return "https://api.minimaxi.com/v1"
|
return "https://api.minimaxi.com/v1"
|
||||||
|
case "siliconflow":
|
||||||
|
return "https://api.siliconflow.cn/v1"
|
||||||
default:
|
default:
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -64,6 +64,12 @@ func TestExtractProtocol(t *testing.T) {
|
||||||
wantProtocol: "nvidia",
|
wantProtocol: "nvidia",
|
||||||
wantModelID: "meta/llama-3.1-8b",
|
wantModelID: "meta/llama-3.1-8b",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "siliconflow with nested model path",
|
||||||
|
model: "siliconflow/Pro/zai-org/GLM-4.7",
|
||||||
|
wantProtocol: "siliconflow",
|
||||||
|
wantModelID: "Pro/zai-org/GLM-4.7",
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
|
|
@ -113,6 +119,7 @@ func TestCreateProviderFromConfig_DefaultAPIBase(t *testing.T) {
|
||||||
{"vllm", "vllm"},
|
{"vllm", "vllm"},
|
||||||
{"deepseek", "deepseek"},
|
{"deepseek", "deepseek"},
|
||||||
{"ollama", "ollama"},
|
{"ollama", "ollama"},
|
||||||
|
{"siliconflow", "siliconflow"},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
|
|
@ -142,6 +149,12 @@ func TestGetDefaultAPIBase_LiteLLM(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestGetDefaultAPIBase_SiliconFlow(t *testing.T) {
|
||||||
|
if got := getDefaultAPIBase("siliconflow"); got != "https://api.siliconflow.cn/v1" {
|
||||||
|
t.Fatalf("getDefaultAPIBase(%q) = %q, want %q", "siliconflow", got, "https://api.siliconflow.cn/v1")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestCreateProviderFromConfig_LiteLLM(t *testing.T) {
|
func TestCreateProviderFromConfig_LiteLLM(t *testing.T) {
|
||||||
cfg := &config.ModelConfig{
|
cfg := &config.ModelConfig{
|
||||||
ModelName: "test-litellm",
|
ModelName: "test-litellm",
|
||||||
|
|
@ -256,7 +269,30 @@ func TestCreateProviderFromConfig_UnknownProtocol(t *testing.T) {
|
||||||
|
|
||||||
_, _, err := CreateProviderFromConfig(cfg)
|
_, _, err := CreateProviderFromConfig(cfg)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatal("CreateProviderFromConfig() expected error for unknown protocol")
|
t.Fatal("CreateProviderFromConfig() expected error for unknown protocol without api_base")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreateProviderFromConfig_UnknownProtocolWithAPIBaseFallsBackToHTTP(t *testing.T) {
|
||||||
|
cfg := &config.ModelConfig{
|
||||||
|
ModelName: "test-unknown-fallback",
|
||||||
|
Model: "Pro/zai-org/GLM-4.7",
|
||||||
|
APIKey: "test-key",
|
||||||
|
APIBase: "https://api.siliconflow.cn/v1",
|
||||||
|
}
|
||||||
|
|
||||||
|
provider, modelID, err := CreateProviderFromConfig(cfg)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateProviderFromConfig() error = %v", err)
|
||||||
|
}
|
||||||
|
if provider == nil {
|
||||||
|
t.Fatal("CreateProviderFromConfig() returned nil provider")
|
||||||
|
}
|
||||||
|
if _, ok := provider.(*HTTPProvider); !ok {
|
||||||
|
t.Fatalf("expected *HTTPProvider, got %T", provider)
|
||||||
|
}
|
||||||
|
if modelID != "Pro/zai-org/GLM-4.7" {
|
||||||
|
t.Errorf("modelID = %q, want %q", modelID, "Pro/zai-org/GLM-4.7")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -317,3 +353,25 @@ func TestCreateProviderFromConfig_RequestTimeoutPropagation(t *testing.T) {
|
||||||
t.Fatalf("Chat() error = %q, want timeout-related error", errMsg)
|
t.Fatalf("Chat() error = %q, want timeout-related error", errMsg)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCreateProviderFromConfig_SiliconFlowNestedModelPath(t *testing.T) {
|
||||||
|
cfg := &config.ModelConfig{
|
||||||
|
ModelName: "test-siliconflow",
|
||||||
|
Model: "siliconflow/Pro/zai-org/GLM-4.7",
|
||||||
|
APIKey: "test-key",
|
||||||
|
}
|
||||||
|
|
||||||
|
provider, modelID, err := CreateProviderFromConfig(cfg)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateProviderFromConfig() error = %v", err)
|
||||||
|
}
|
||||||
|
if provider == nil {
|
||||||
|
t.Fatal("CreateProviderFromConfig() returned nil provider")
|
||||||
|
}
|
||||||
|
if _, ok := provider.(*HTTPProvider); !ok {
|
||||||
|
t.Fatalf("expected *HTTPProvider, got %T", provider)
|
||||||
|
}
|
||||||
|
if modelID != "Pro/zai-org/GLM-4.7" {
|
||||||
|
t.Errorf("modelID = %q, want %q", modelID, "Pro/zai-org/GLM-4.7")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -117,7 +117,7 @@ func (p *Provider) Chat(
|
||||||
|
|
||||||
requestBody := map[string]any{
|
requestBody := map[string]any{
|
||||||
"model": model,
|
"model": model,
|
||||||
"messages": serializeMessages(messages),
|
"messages": serializeMessagesForProvider(messages, p.apiBase, model),
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(tools) > 0 {
|
if len(tools) > 0 {
|
||||||
|
|
@ -150,6 +150,11 @@ func (p *Provider) Chat(
|
||||||
requestBody["temperature"] = temperature
|
requestBody["temperature"] = temperature
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if strings.Contains(strings.ToLower(model), "glm-4.6v") {
|
||||||
|
if level, _ := options["thinking_level"].(string); strings.TrimSpace(level) != "" && strings.ToLower(level) != "off" {
|
||||||
|
requestBody["thinking"] = map[string]any{"type": "enabled"}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Prompt caching: pass a stable cache key so OpenAI can bucket requests
|
// Prompt caching: pass a stable cache key so OpenAI can bucket requests
|
||||||
// with the same key and reuse prefix KV cache across calls.
|
// with the same key and reuse prefix KV cache across calls.
|
||||||
|
|
@ -172,6 +177,11 @@ func (p *Provider) Chat(
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||||
}
|
}
|
||||||
|
requestCtx := requestErrorContext{
|
||||||
|
APIBase: p.apiBase,
|
||||||
|
Model: model,
|
||||||
|
Path: req.URL.Path,
|
||||||
|
}
|
||||||
|
|
||||||
req.Header.Set("Content-Type", "application/json")
|
req.Header.Set("Content-Type", "application/json")
|
||||||
if p.apiKey != "" {
|
if p.apiKey != "" {
|
||||||
|
|
@ -193,13 +203,9 @@ func (p *Provider) Chat(
|
||||||
return nil, fmt.Errorf("failed to read response: %w", readErr)
|
return nil, fmt.Errorf("failed to read response: %w", readErr)
|
||||||
}
|
}
|
||||||
if looksLikeHTML(body, contentType) {
|
if looksLikeHTML(body, contentType) {
|
||||||
return nil, wrapHTMLResponseError(resp.StatusCode, body, contentType, p.apiBase)
|
return nil, wrapHTMLResponseError(resp.StatusCode, body, contentType, requestCtx)
|
||||||
}
|
}
|
||||||
return nil, fmt.Errorf(
|
return nil, formatAPIError(resp.StatusCode, body, requestCtx)
|
||||||
"API request failed:\n Status: %d\n Body: %s",
|
|
||||||
resp.StatusCode,
|
|
||||||
responsePreview(body, 128),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Peek without consuming so the full stream reaches the JSON decoder.
|
// Peek without consuming so the full stream reaches the JSON decoder.
|
||||||
|
|
@ -209,7 +215,7 @@ func (p *Provider) Chat(
|
||||||
return nil, fmt.Errorf("failed to inspect response: %w", err)
|
return nil, fmt.Errorf("failed to inspect response: %w", err)
|
||||||
}
|
}
|
||||||
if looksLikeHTML(prefix, contentType) {
|
if looksLikeHTML(prefix, contentType) {
|
||||||
return nil, wrapHTMLResponseError(resp.StatusCode, prefix, contentType, p.apiBase)
|
return nil, wrapHTMLResponseError(resp.StatusCode, prefix, contentType, requestCtx)
|
||||||
}
|
}
|
||||||
|
|
||||||
out, err := parseResponse(reader)
|
out, err := parseResponse(reader)
|
||||||
|
|
@ -220,17 +226,130 @@ func (p *Provider) Chat(
|
||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func wrapHTMLResponseError(statusCode int, body []byte, contentType, apiBase string) error {
|
type requestErrorContext struct {
|
||||||
|
APIBase string
|
||||||
|
Model string
|
||||||
|
Path string
|
||||||
|
}
|
||||||
|
|
||||||
|
func wrapHTMLResponseError(statusCode int, body []byte, contentType string, requestCtx requestErrorContext) error {
|
||||||
respPreview := responsePreview(body, 128)
|
respPreview := responsePreview(body, 128)
|
||||||
return fmt.Errorf(
|
return fmt.Errorf(
|
||||||
"API request failed: %s returned HTML instead of JSON (content-type: %s); check api_base or proxy configuration.\n Status: %d\n Body: %s",
|
"API request failed: %s returned HTML instead of JSON (content-type: %s); check api_base or proxy configuration.\n Status: %d\n API Base: %s\n Model: %s\n Path: %s\n Body: %s",
|
||||||
apiBase,
|
requestCtx.APIBase,
|
||||||
contentType,
|
contentType,
|
||||||
statusCode,
|
statusCode,
|
||||||
|
requestCtx.APIBase,
|
||||||
|
requestCtx.Model,
|
||||||
|
requestCtx.Path,
|
||||||
respPreview,
|
respPreview,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func formatAPIError(statusCode int, body []byte, requestCtx requestErrorContext) error {
|
||||||
|
if details, ok := extractStructuredAPIError(body); ok {
|
||||||
|
if details.Code != "" {
|
||||||
|
return fmt.Errorf(
|
||||||
|
"API request failed:\n Status: %d\n API Base: %s\n Model: %s\n Path: %s\n Message: %s\n Code: %s\n Body: %s",
|
||||||
|
statusCode,
|
||||||
|
requestCtx.APIBase,
|
||||||
|
requestCtx.Model,
|
||||||
|
requestCtx.Path,
|
||||||
|
details.Message,
|
||||||
|
details.Code,
|
||||||
|
responsePreview(body, 128),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return fmt.Errorf(
|
||||||
|
"API request failed:\n Status: %d\n API Base: %s\n Model: %s\n Path: %s\n Message: %s\n Body: %s",
|
||||||
|
statusCode,
|
||||||
|
requestCtx.APIBase,
|
||||||
|
requestCtx.Model,
|
||||||
|
requestCtx.Path,
|
||||||
|
details.Message,
|
||||||
|
responsePreview(body, 128),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return fmt.Errorf(
|
||||||
|
"API request failed:\n Status: %d\n API Base: %s\n Model: %s\n Path: %s\n Body: %s",
|
||||||
|
statusCode,
|
||||||
|
requestCtx.APIBase,
|
||||||
|
requestCtx.Model,
|
||||||
|
requestCtx.Path,
|
||||||
|
responsePreview(body, 128),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
type structuredAPIError struct {
|
||||||
|
Message string
|
||||||
|
Code string
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractStructuredAPIError(body []byte) (structuredAPIError, bool) {
|
||||||
|
trimmed := bytes.TrimSpace(body)
|
||||||
|
if len(trimmed) == 0 || trimmed[0] != '{' {
|
||||||
|
return structuredAPIError{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
var payload map[string]any
|
||||||
|
if err := json.Unmarshal(trimmed, &payload); err != nil {
|
||||||
|
return structuredAPIError{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
details := structuredAPIError{}
|
||||||
|
if msg := stringifyErrorField(payload["message"]); msg != "" {
|
||||||
|
details.Message = msg
|
||||||
|
}
|
||||||
|
if code := stringifyErrorField(payload["code"]); code != "" {
|
||||||
|
details.Code = code
|
||||||
|
}
|
||||||
|
|
||||||
|
if errField, ok := payload["error"]; ok {
|
||||||
|
switch e := errField.(type) {
|
||||||
|
case string:
|
||||||
|
if details.Message == "" {
|
||||||
|
details.Message = e
|
||||||
|
}
|
||||||
|
case map[string]any:
|
||||||
|
if details.Message == "" {
|
||||||
|
details.Message = stringifyErrorField(e["message"])
|
||||||
|
}
|
||||||
|
if details.Code == "" {
|
||||||
|
details.Code = stringifyErrorField(e["code"])
|
||||||
|
}
|
||||||
|
if details.Message == "" {
|
||||||
|
details.Message = stringifyErrorField(e["type"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if details.Message == "" {
|
||||||
|
return structuredAPIError{}, false
|
||||||
|
}
|
||||||
|
return details, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func stringifyErrorField(v any) string {
|
||||||
|
switch val := v.(type) {
|
||||||
|
case string:
|
||||||
|
return strings.TrimSpace(val)
|
||||||
|
case json.Number:
|
||||||
|
return val.String()
|
||||||
|
case float64:
|
||||||
|
return strings.TrimSpace(fmt.Sprintf("%.0f", val))
|
||||||
|
case float32:
|
||||||
|
return strings.TrimSpace(fmt.Sprintf("%.0f", val))
|
||||||
|
case int:
|
||||||
|
return fmt.Sprintf("%d", val)
|
||||||
|
case int64:
|
||||||
|
return fmt.Sprintf("%d", val)
|
||||||
|
case int32:
|
||||||
|
return fmt.Sprintf("%d", val)
|
||||||
|
default:
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func looksLikeHTML(body []byte, contentType string) bool {
|
func looksLikeHTML(body []byte, contentType string) bool {
|
||||||
contentType = strings.ToLower(strings.TrimSpace(contentType))
|
contentType = strings.ToLower(strings.TrimSpace(contentType))
|
||||||
if strings.Contains(contentType, "text/html") || strings.Contains(contentType, "application/xhtml+xml") {
|
if strings.Contains(contentType, "text/html") || strings.Contains(contentType, "application/xhtml+xml") {
|
||||||
|
|
@ -377,6 +496,12 @@ type openaiMessage struct {
|
||||||
// - Converts messages with Media to multipart content format (text + image_url parts)
|
// - Converts messages with Media to multipart content format (text + image_url parts)
|
||||||
// - Preserves ToolCallID, ToolCalls, and ReasoningContent for all messages
|
// - Preserves ToolCallID, ToolCalls, and ReasoningContent for all messages
|
||||||
func serializeMessages(messages []Message) []any {
|
func serializeMessages(messages []Message) []any {
|
||||||
|
return serializeMessagesForProvider(messages, "", "")
|
||||||
|
}
|
||||||
|
|
||||||
|
func serializeMessagesForProvider(messages []Message, apiBase, model string) []any {
|
||||||
|
useObjectImageURL := usesObjectImageURL(apiBase, model)
|
||||||
|
preferImageFirst := usesImageFirstOrdering(apiBase, model)
|
||||||
out := make([]any, 0, len(messages))
|
out := make([]any, 0, len(messages))
|
||||||
for _, m := range messages {
|
for _, m := range messages {
|
||||||
if len(m.Media) == 0 {
|
if len(m.Media) == 0 {
|
||||||
|
|
@ -390,29 +515,35 @@ func serializeMessages(messages []Message) []any {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Multipart content format for messages with media
|
|
||||||
parts := make([]map[string]any, 0, 1+len(m.Media))
|
parts := make([]map[string]any, 0, 1+len(m.Media))
|
||||||
if m.Content != "" {
|
appendImageParts := func() {
|
||||||
parts = append(parts, map[string]any{
|
|
||||||
"type": "text",
|
|
||||||
"text": m.Content,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
for _, mediaURL := range m.Media {
|
for _, mediaURL := range m.Media {
|
||||||
if strings.HasPrefix(mediaURL, "data:image/") {
|
if !strings.HasPrefix(mediaURL, "data:image/") && !strings.HasPrefix(mediaURL, "http://") && !strings.HasPrefix(mediaURL, "https://") {
|
||||||
parts = append(parts, map[string]any{
|
continue
|
||||||
"type": "image_url",
|
|
||||||
"image_url": map[string]any{
|
|
||||||
"url": mediaURL,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
part := map[string]any{"type": "image_url"}
|
||||||
|
if useObjectImageURL {
|
||||||
|
part["image_url"] = map[string]any{"url": mediaURL}
|
||||||
|
} else {
|
||||||
|
part["image_url"] = mediaURL
|
||||||
|
}
|
||||||
|
parts = append(parts, part)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
appendTextPart := func() {
|
||||||
|
if m.Content != "" {
|
||||||
|
parts = append(parts, map[string]any{"type": "text", "text": m.Content})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if preferImageFirst {
|
||||||
|
appendImageParts()
|
||||||
|
appendTextPart()
|
||||||
|
} else {
|
||||||
|
appendTextPart()
|
||||||
|
appendImageParts()
|
||||||
}
|
}
|
||||||
|
|
||||||
msg := map[string]any{
|
msg := map[string]any{"role": m.Role, "content": parts}
|
||||||
"role": m.Role,
|
|
||||||
"content": parts,
|
|
||||||
}
|
|
||||||
if m.ToolCallID != "" {
|
if m.ToolCallID != "" {
|
||||||
msg["tool_call_id"] = m.ToolCallID
|
msg["tool_call_id"] = m.ToolCallID
|
||||||
}
|
}
|
||||||
|
|
@ -427,6 +558,18 @@ func serializeMessages(messages []Message) []any {
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func usesImageFirstOrdering(apiBase, model string) bool {
|
||||||
|
base := strings.ToLower(strings.TrimSpace(apiBase))
|
||||||
|
m := strings.ToLower(strings.TrimSpace(model))
|
||||||
|
return strings.Contains(base, "bigmodel.cn") || strings.Contains(base, "zhipu") || strings.Contains(m, "glm-4.6v")
|
||||||
|
}
|
||||||
|
|
||||||
|
func usesObjectImageURL(apiBase, model string) bool {
|
||||||
|
base := strings.ToLower(strings.TrimSpace(apiBase))
|
||||||
|
m := strings.ToLower(strings.TrimSpace(model))
|
||||||
|
return strings.Contains(base, "bigmodel.cn") || strings.Contains(base, "zhipu") || strings.Contains(m, "glm")
|
||||||
|
}
|
||||||
|
|
||||||
func normalizeModel(model, apiBase string) string {
|
func normalizeModel(model, apiBase string) string {
|
||||||
before, after, ok := strings.Cut(model, "/")
|
before, after, ok := strings.Cut(model, "/")
|
||||||
if !ok {
|
if !ok {
|
||||||
|
|
|
||||||
|
|
@ -228,14 +228,112 @@ func TestProviderChat_JSONHTTPErrorDoesNotReportHTML(t *testing.T) {
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatal("expected error, got nil")
|
t.Fatal("expected error, got nil")
|
||||||
}
|
}
|
||||||
if !strings.Contains(err.Error(), "Status: 400") {
|
errMsg := err.Error()
|
||||||
|
if !strings.Contains(errMsg, "Status: 400") {
|
||||||
t.Fatalf("expected status code in error, got %v", err)
|
t.Fatalf("expected status code in error, got %v", err)
|
||||||
}
|
}
|
||||||
if strings.Contains(err.Error(), "returned HTML instead of JSON") {
|
if !strings.Contains(errMsg, "API Base: "+server.URL) {
|
||||||
|
t.Fatalf("expected api base in error, got %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(errMsg, "Model: gpt-4o") {
|
||||||
|
t.Fatalf("expected model in error, got %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(errMsg, "Path: /chat/completions") {
|
||||||
|
t.Fatalf("expected request path in error, got %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(errMsg, "Message: bad request") {
|
||||||
|
t.Fatalf("expected parsed message in error, got %v", err)
|
||||||
|
}
|
||||||
|
if strings.Contains(errMsg, "returned HTML instead of JSON") {
|
||||||
t.Fatalf("expected non-HTML http error, got %v", err)
|
t.Fatalf("expected non-HTML http error, got %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestProviderChat_JSONHTTPErrorIncludesFlatCodeAndMessage(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
|
_, _ = w.Write([]byte(`{"code":20012,"message":"Model does not exist. Please check it carefully."}`))
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
p := NewProvider("key", server.URL, "")
|
||||||
|
_, err := p.Chat(t.Context(), []Message{{Role: "user", Content: "hi"}}, nil, "gpt-4o", nil)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error, got nil")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "Message: Model does not exist. Please check it carefully.") {
|
||||||
|
t.Fatalf("expected upstream message in error, got %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "Code: 20012") {
|
||||||
|
t.Fatalf("expected upstream code in error, got %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "Model: gpt-4o") {
|
||||||
|
t.Fatalf("expected model context in error, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProviderChat_JSONHTTPErrorIncludesNestedOpenAIError(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
|
_, _ = w.Write([]byte(`{"error":{"code":"invalid_api_key","message":"The provided API key is invalid."}}`))
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
p := NewProvider("key", server.URL, "")
|
||||||
|
_, err := p.Chat(t.Context(), []Message{{Role: "user", Content: "hi"}}, nil, "gpt-4o", nil)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error, got nil")
|
||||||
|
}
|
||||||
|
errMsg := err.Error()
|
||||||
|
if !strings.Contains(errMsg, "Status: 400") {
|
||||||
|
t.Fatalf("expected status code in error, got %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(errMsg, "API Base: "+server.URL) {
|
||||||
|
t.Fatalf("expected api base in error, got %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(errMsg, "Model: gpt-4o") {
|
||||||
|
t.Fatalf("expected model in error, got %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(errMsg, "Path: /chat/completions") {
|
||||||
|
t.Fatalf("expected request path in error, got %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(errMsg, "Message: The provided API key is invalid.") {
|
||||||
|
t.Fatalf("expected parsed message in error, got %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(errMsg, "Code: invalid_api_key") {
|
||||||
|
t.Fatalf("expected nested error code in error, got %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(errMsg, "Path: /chat/completions") {
|
||||||
|
t.Fatalf("expected request path in error, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProviderChat_MalformedJSONHTTPErrorFallsBackToBodyPreview(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
|
_, _ = w.Write([]byte(`{"message":"unterminated`))
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
p := NewProvider("key", server.URL, "")
|
||||||
|
_, err := p.Chat(t.Context(), []Message{{Role: "user", Content: "hi"}}, nil, "gpt-4o", nil)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error, got nil")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "Body: {\"message\":\"unterminated") {
|
||||||
|
t.Fatalf("expected raw body preview fallback, got %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "API Base: "+server.URL) {
|
||||||
|
t.Fatalf("expected api base context in fallback error, got %v", err)
|
||||||
|
}
|
||||||
|
if strings.Contains(err.Error(), "Message:") {
|
||||||
|
t.Fatalf("expected no parsed message for malformed json, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestProviderChat_HTMLResponsesReturnHelpfulError(t *testing.T) {
|
func TestProviderChat_HTMLResponsesReturnHelpfulError(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
|
|
@ -283,6 +381,15 @@ func TestProviderChat_HTMLResponsesReturnHelpfulError(t *testing.T) {
|
||||||
if !strings.Contains(err.Error(), "returned HTML instead of JSON") {
|
if !strings.Contains(err.Error(), "returned HTML instead of JSON") {
|
||||||
t.Fatalf("expected helpful HTML error, got %v", err)
|
t.Fatalf("expected helpful HTML error, got %v", err)
|
||||||
}
|
}
|
||||||
|
if !strings.Contains(err.Error(), "API Base: "+server.URL) {
|
||||||
|
t.Fatalf("expected api base context, got %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "Model: gpt-4o") {
|
||||||
|
t.Fatalf("expected model context, got %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "Path: /chat/completions") {
|
||||||
|
t.Fatalf("expected request path context, got %v", err)
|
||||||
|
}
|
||||||
if !strings.Contains(err.Error(), "check api_base or proxy configuration") {
|
if !strings.Contains(err.Error(), "check api_base or proxy configuration") {
|
||||||
t.Fatalf("expected configuration hint, got %v", err)
|
t.Fatalf("expected configuration hint, got %v", err)
|
||||||
}
|
}
|
||||||
|
|
@ -644,9 +751,37 @@ func TestSerializeMessages_WithMedia(t *testing.T) {
|
||||||
if imgPart["type"] != "image_url" {
|
if imgPart["type"] != "image_url" {
|
||||||
t.Fatalf("expected image_url type, got %v", imgPart["type"])
|
t.Fatalf("expected image_url type, got %v", imgPart["type"])
|
||||||
}
|
}
|
||||||
imgURL := imgPart["image_url"].(map[string]any)
|
if imgPart["image_url"] != "data:image/png;base64,abc123" {
|
||||||
|
t.Fatalf("image url mismatch: %v", imgPart["image_url"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSerializeMessages_WithMediaForGLM(t *testing.T) {
|
||||||
|
messages := []protocoltypes.Message{{Role: "user", Content: "describe this", Media: []string{"data:image/png;base64,abc123"}}}
|
||||||
|
result := serializeMessagesForProvider(messages, "https://open.bigmodel.cn/api/paas/v4", "glm-4.6v-flash")
|
||||||
|
|
||||||
|
data, _ := json.Marshal(result)
|
||||||
|
var msgs []map[string]any
|
||||||
|
json.Unmarshal(data, &msgs)
|
||||||
|
|
||||||
|
content := msgs[0]["content"].([]any)
|
||||||
|
if len(content) != 2 {
|
||||||
|
t.Fatalf("expected 2 content parts, got %d", len(content))
|
||||||
|
}
|
||||||
|
first := content[0].(map[string]any)
|
||||||
|
second := content[1].(map[string]any)
|
||||||
|
if first["type"] != "image_url" {
|
||||||
|
t.Fatalf("expected image first for glm, got first=%v second=%v", first, second)
|
||||||
|
}
|
||||||
|
imgURL, ok := first["image_url"].(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("expected object-form image_url for glm, got %T (%v)", first["image_url"], first["image_url"])
|
||||||
|
}
|
||||||
if imgURL["url"] != "data:image/png;base64,abc123" {
|
if imgURL["url"] != "data:image/png;base64,abc123" {
|
||||||
t.Fatalf("image url mismatch: %v", imgURL["url"])
|
t.Fatalf("glm image url mismatch: %v", imgURL["url"])
|
||||||
|
}
|
||||||
|
if second["type"] != "text" || second["text"] != "describe this" {
|
||||||
|
t.Fatalf("glm text part mismatch: %v", second)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
643
pkg/tools/feishu.go
Normal file
643
pkg/tools/feishu.go
Normal file
|
|
@ -0,0 +1,643 @@
|
||||||
|
package tools
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
type FeishuParseTool struct{}
|
||||||
|
|
||||||
|
func NewFeishuParseTool() *FeishuParseTool {
|
||||||
|
return &FeishuParseTool{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *FeishuParseTool) Name() string {
|
||||||
|
return "feishu_parse"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *FeishuParseTool) Description() string {
|
||||||
|
return "Parse Feishu message content, card JSON, or share links into a structured summary without calling remote APIs. Useful for reasoning about Feishu payloads locally."
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *FeishuParseTool) Parameters() map[string]any {
|
||||||
|
return map[string]any{
|
||||||
|
"type": "object",
|
||||||
|
"properties": map[string]any{
|
||||||
|
"mode": map[string]any{
|
||||||
|
"type": "string",
|
||||||
|
"description": "One of: message_content, card, share_link",
|
||||||
|
"enum": []string{"message_content", "card", "share_link"},
|
||||||
|
},
|
||||||
|
"content": map[string]any{
|
||||||
|
"type": "string",
|
||||||
|
"description": "Raw Feishu JSON string or share link to parse",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": []string{"mode", "content"},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *FeishuParseTool) Execute(ctx context.Context, args map[string]any) *ToolResult {
|
||||||
|
_ = ctx
|
||||||
|
mode, _ := args["mode"].(string)
|
||||||
|
content, _ := args["content"].(string)
|
||||||
|
if strings.TrimSpace(mode) == "" || strings.TrimSpace(content) == "" {
|
||||||
|
return ErrorResult("mode and content are required")
|
||||||
|
}
|
||||||
|
|
||||||
|
switch mode {
|
||||||
|
case "share_link":
|
||||||
|
result := map[string]any{"token": extractFeishuShareToken(content)}
|
||||||
|
if decoded := decodeFeishuShareToken(result["token"].(string)); decoded != "" {
|
||||||
|
result["decoded_message_id"] = decoded
|
||||||
|
}
|
||||||
|
return structuredToolResult("feishu share link parsed", result)
|
||||||
|
case "message_content":
|
||||||
|
var body map[string]any
|
||||||
|
if err := json.Unmarshal([]byte(content), &body); err != nil {
|
||||||
|
return ErrorResult(fmt.Sprintf("invalid message content JSON: %v", err)).WithError(err)
|
||||||
|
}
|
||||||
|
result := map[string]any{
|
||||||
|
"text": firstJSONStringField(body, "text"),
|
||||||
|
"image_key": firstJSONStringFieldDeep(body, "image_key"),
|
||||||
|
"file_key": firstJSONStringFieldDeep(body, "file_key"),
|
||||||
|
"file_name": firstJSONStringFieldDeep(body, "file_name"),
|
||||||
|
"raw": body,
|
||||||
|
}
|
||||||
|
return structuredToolResult("feishu message content parsed", result)
|
||||||
|
case "card":
|
||||||
|
var card map[string]any
|
||||||
|
if err := json.Unmarshal([]byte(content), &card); err != nil {
|
||||||
|
return ErrorResult(fmt.Sprintf("invalid card JSON: %v", err)).WithError(err)
|
||||||
|
}
|
||||||
|
summary := summarizeFeishuCard(card)
|
||||||
|
return structuredToolResult("feishu card parsed", summary)
|
||||||
|
default:
|
||||||
|
return ErrorResult("unsupported mode")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func structuredToolResult(prefix string, value any) *ToolResult {
|
||||||
|
data, err := json.MarshalIndent(value, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return ErrorResult(fmt.Sprintf("marshal result: %v", err)).WithError(err)
|
||||||
|
}
|
||||||
|
return UserResult(prefix + "\n" + string(data))
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractFeishuShareToken(link string) string {
|
||||||
|
const marker = "token="
|
||||||
|
idx := strings.Index(link, marker)
|
||||||
|
if idx < 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
token := link[idx+len(marker):]
|
||||||
|
if cut := strings.Index(token, "&"); cut >= 0 {
|
||||||
|
token = token[:cut]
|
||||||
|
}
|
||||||
|
return token
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeFeishuShareToken(token string) string {
|
||||||
|
replacer := strings.NewReplacer("%3D", "=", "%3d", "=")
|
||||||
|
return replacer.Replace(token)
|
||||||
|
}
|
||||||
|
|
||||||
|
func firstJSONStringField(body map[string]any, field string) string {
|
||||||
|
if body == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if v, ok := body[field].(string); ok {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func firstJSONStringFieldDeep(v any, field string) string {
|
||||||
|
switch vv := v.(type) {
|
||||||
|
case map[string]any:
|
||||||
|
if value, ok := vv[field].(string); ok && strings.TrimSpace(value) != "" {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
for _, nested := range vv {
|
||||||
|
if found := firstJSONStringFieldDeep(nested, field); found != "" {
|
||||||
|
return found
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case []any:
|
||||||
|
for _, nested := range vv {
|
||||||
|
if found := firstJSONStringFieldDeep(nested, field); found != "" {
|
||||||
|
return found
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func summarizeFeishuCard(card map[string]any) map[string]any {
|
||||||
|
summary := map[string]any{
|
||||||
|
"title": "",
|
||||||
|
"components": []map[string]any{},
|
||||||
|
"text_contents": []string{},
|
||||||
|
"image_keys": []string{},
|
||||||
|
"action_buttons": []string{},
|
||||||
|
}
|
||||||
|
if header, ok := card["header"].(map[string]any); ok {
|
||||||
|
if title, ok := header["title"].(map[string]any); ok {
|
||||||
|
if content, ok := title["content"].(string); ok {
|
||||||
|
summary["title"] = content
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if title, ok := card["title"].(string); ok && title != "" {
|
||||||
|
summary["title"] = title
|
||||||
|
}
|
||||||
|
var elements []any
|
||||||
|
if raw, ok := card["elements"].([]any); ok {
|
||||||
|
elements = raw
|
||||||
|
} else if body, ok := card["body"].(map[string]any); ok {
|
||||||
|
if raw, ok := body["elements"].([]any); ok {
|
||||||
|
elements = raw
|
||||||
|
}
|
||||||
|
} else if raw, ok := card["content"].([]any); ok {
|
||||||
|
elements = raw
|
||||||
|
}
|
||||||
|
texts := make([]string, 0)
|
||||||
|
images := make([]string, 0)
|
||||||
|
buttons := make([]string, 0)
|
||||||
|
components := make([]map[string]any, 0, len(elements))
|
||||||
|
for i, raw := range elements {
|
||||||
|
element, _ := raw.(map[string]any)
|
||||||
|
tag, _ := element["tag"].(string)
|
||||||
|
component := map[string]any{"index": i + 1, "tag": tag, "details": map[string]any{}}
|
||||||
|
details := component["details"].(map[string]any)
|
||||||
|
switch tag {
|
||||||
|
case "div", "note", "header":
|
||||||
|
if text, ok := element["text"].(map[string]any); ok {
|
||||||
|
if content, ok := text["content"].(string); ok && content != "" {
|
||||||
|
texts = append(texts, content)
|
||||||
|
details["text"] = content
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if fields, ok := element["fields"].([]any); ok && len(fields) > 0 {
|
||||||
|
details["fields"] = fields
|
||||||
|
details["field_count"] = len(fields)
|
||||||
|
}
|
||||||
|
case "text":
|
||||||
|
if content, ok := element["text"].(string); ok && content != "" {
|
||||||
|
texts = append(texts, content)
|
||||||
|
details["text"] = content
|
||||||
|
}
|
||||||
|
if mode, ok := element["mode"].(string); ok && mode != "" {
|
||||||
|
details["mode"] = mode
|
||||||
|
}
|
||||||
|
case "img":
|
||||||
|
if key, ok := element["image_key"].(string); ok && key != "" {
|
||||||
|
images = append(images, key)
|
||||||
|
details["image_key"] = key
|
||||||
|
}
|
||||||
|
if alt, ok := element["alt"]; ok {
|
||||||
|
details["alt"] = alt
|
||||||
|
}
|
||||||
|
case "action":
|
||||||
|
if actions, ok := element["actions"].([]any); ok {
|
||||||
|
parsedActions := make([]map[string]any, 0, len(actions))
|
||||||
|
for _, rawAction := range actions {
|
||||||
|
action, _ := rawAction.(map[string]any)
|
||||||
|
entry := map[string]any{"type": action["type"], "style": action["style"]}
|
||||||
|
if text, ok := action["text"].(map[string]any); ok {
|
||||||
|
if content, ok := text["content"].(string); ok && content != "" {
|
||||||
|
buttons = append(buttons, content)
|
||||||
|
entry["text"] = content
|
||||||
|
}
|
||||||
|
}
|
||||||
|
parsedActions = append(parsedActions, entry)
|
||||||
|
}
|
||||||
|
details["action_count"] = len(actions)
|
||||||
|
details["actions"] = parsedActions
|
||||||
|
}
|
||||||
|
case "at":
|
||||||
|
details["user_id"] = element["user_id"]
|
||||||
|
details["user_name"] = element["user_name"]
|
||||||
|
details["user_avatar"] = element["user_avatar"]
|
||||||
|
default:
|
||||||
|
for k, v := range element {
|
||||||
|
details[k] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
components = append(components, component)
|
||||||
|
}
|
||||||
|
summary["components"] = components
|
||||||
|
summary["text_contents"] = texts
|
||||||
|
summary["image_keys"] = images
|
||||||
|
summary["action_buttons"] = buttons
|
||||||
|
return summary
|
||||||
|
}
|
||||||
|
|
||||||
|
type FeishuRemoteClient interface {
|
||||||
|
GetMessage(ctx context.Context, messageID string) (any, error)
|
||||||
|
ListMessages(ctx context.Context, containerID, containerType string, pageSize int, pageToken string) (any, error)
|
||||||
|
ReplyMessage(ctx context.Context, messageID, text string) error
|
||||||
|
GetMessageFromShareLink(ctx context.Context, shareLink string) (any, error)
|
||||||
|
GetUserInfo(ctx context.Context, userID string) (any, error)
|
||||||
|
ListUsers(ctx context.Context, pageSize int, userIDType, pageToken string) (any, error)
|
||||||
|
GetUserIDByEmail(ctx context.Context, email string) (string, error)
|
||||||
|
GetUserIDByMobile(ctx context.Context, mobile string) (string, error)
|
||||||
|
CreateGroup(ctx context.Context, name string) (any, error)
|
||||||
|
GetGroupInfo(ctx context.Context, chatID string) (any, error)
|
||||||
|
ListGroupMembers(ctx context.Context, chatID string, pageSize int, pageToken string) (any, error)
|
||||||
|
ListGroups(ctx context.Context, pageSize int, pageToken string) (any, error)
|
||||||
|
SendGroupMessage(ctx context.Context, chatID, text string) error
|
||||||
|
GetDriveRootFolder(ctx context.Context) (any, error)
|
||||||
|
GetDriveFolder(ctx context.Context, folderToken string) (any, error)
|
||||||
|
GetDriveFile(ctx context.Context, fileToken string) (any, error)
|
||||||
|
ListDriveFiles(ctx context.Context, folderToken, pageToken string, pageSize int) (any, error)
|
||||||
|
DownloadDriveFile(ctx context.Context, fileToken string) (any, error)
|
||||||
|
DeleteDriveFile(ctx context.Context, fileToken string) error
|
||||||
|
UploadDriveFile(ctx context.Context, parentToken, name string, data []byte) (any, error)
|
||||||
|
InitiateMultipartUpload(ctx context.Context, parentToken, name string, size int64) (any, error)
|
||||||
|
UploadMultipartChunk(ctx context.Context, uploadID string, seq int, data []byte) error
|
||||||
|
CompleteMultipartUpload(ctx context.Context, uploadID string, blockNum int) (any, error)
|
||||||
|
SendImageMessage(ctx context.Context, chatID string, data []byte, fileName string) error
|
||||||
|
SendFileMessage(ctx context.Context, chatID string, data []byte, fileName, fileType string) error
|
||||||
|
}
|
||||||
|
|
||||||
|
type FeishuRemoteTool struct {
|
||||||
|
client FeishuRemoteClient
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewFeishuRemoteTool(client FeishuRemoteClient) *FeishuRemoteTool {
|
||||||
|
return &FeishuRemoteTool{client: client}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *FeishuRemoteTool) Name() string {
|
||||||
|
return "feishu_remote"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *FeishuRemoteTool) Description() string {
|
||||||
|
return "Query and operate on Feishu messages, users, groups, Drive files, and multipart uploads through an injected Feishu client."
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *FeishuRemoteTool) Parameters() map[string]any {
|
||||||
|
return map[string]any{
|
||||||
|
"type": "object",
|
||||||
|
"properties": map[string]any{
|
||||||
|
"action": map[string]any{
|
||||||
|
"type": "string",
|
||||||
|
"description": "Feishu remote action to run",
|
||||||
|
"enum": []string{
|
||||||
|
"get_message", "list_messages", "reply_message", "get_message_from_share_link",
|
||||||
|
"get_user", "list_users", "get_user_id_by_email", "get_user_id_by_mobile", "get_user_id_by_phone",
|
||||||
|
"create_group", "get_group", "list_group_members", "list_groups", "send_group_message",
|
||||||
|
"get_drive_root_folder", "get_drive_folder", "get_drive_file", "list_drive_files",
|
||||||
|
"download_drive_file", "download_image_to_bytes", "download_image_from_message", "delete_drive_file", "upload_drive_file",
|
||||||
|
"initiate_multipart_upload", "upload_multipart_chunk", "complete_multipart_upload",
|
||||||
|
"send_image", "send_image_from_url", "send_file",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"id": map[string]any{
|
||||||
|
"type": "string",
|
||||||
|
"description": "Primary identifier such as message ID, chat ID, user ID, share link, file token, or upload ID depending on action",
|
||||||
|
},
|
||||||
|
"text": map[string]any{
|
||||||
|
"type": "string",
|
||||||
|
"description": "Text payload used by reply_message and send_group_message",
|
||||||
|
},
|
||||||
|
"name": map[string]any{
|
||||||
|
"type": "string",
|
||||||
|
"description": "Group name or upload file name depending on action",
|
||||||
|
},
|
||||||
|
"container_type": map[string]any{
|
||||||
|
"type": "string",
|
||||||
|
"description": "For list_messages only; defaults to chat",
|
||||||
|
},
|
||||||
|
"user_id_type": map[string]any{
|
||||||
|
"type": "string",
|
||||||
|
"description": "For list_users only; defaults to open_id",
|
||||||
|
},
|
||||||
|
"page_size": map[string]any{
|
||||||
|
"type": "integer",
|
||||||
|
"description": "Page size for list operations",
|
||||||
|
},
|
||||||
|
"page_token": map[string]any{
|
||||||
|
"type": "string",
|
||||||
|
"description": "Page token for list operations",
|
||||||
|
},
|
||||||
|
"parent_token": map[string]any{
|
||||||
|
"type": "string",
|
||||||
|
"description": "Parent folder token for drive upload or multipart prepare",
|
||||||
|
},
|
||||||
|
"data_base64": map[string]any{
|
||||||
|
"type": "string",
|
||||||
|
"description": "Base64-encoded bytes for upload_drive_file or upload_multipart_chunk",
|
||||||
|
},
|
||||||
|
"seq": map[string]any{
|
||||||
|
"type": "integer",
|
||||||
|
"description": "Chunk sequence number for upload_multipart_chunk",
|
||||||
|
},
|
||||||
|
"size": map[string]any{
|
||||||
|
"type": "integer",
|
||||||
|
"description": "File size for initiate_multipart_upload",
|
||||||
|
},
|
||||||
|
"block_num": map[string]any{
|
||||||
|
"type": "integer",
|
||||||
|
"description": "Uploaded chunk count for complete_multipart_upload",
|
||||||
|
},
|
||||||
|
"file_type": map[string]any{
|
||||||
|
"type": "string",
|
||||||
|
"description": "Optional file type for send_file, such as stream, audio, or video",
|
||||||
|
},
|
||||||
|
"url": map[string]any{
|
||||||
|
"type": "string",
|
||||||
|
"description": "Image URL for send_image_from_url",
|
||||||
|
},
|
||||||
|
"resource_id": map[string]any{
|
||||||
|
"type": "string",
|
||||||
|
"description": "Optional image token or file token used by download_image_from_message",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": []string{"action"},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *FeishuRemoteTool) Execute(ctx context.Context, args map[string]any) *ToolResult {
|
||||||
|
if t.client == nil {
|
||||||
|
return ErrorResult("feishu remote client is not configured")
|
||||||
|
}
|
||||||
|
action, _ := args["action"].(string)
|
||||||
|
if strings.TrimSpace(action) == "" {
|
||||||
|
return ErrorResult("action is required")
|
||||||
|
}
|
||||||
|
id, _ := args["id"].(string)
|
||||||
|
text, _ := args["text"].(string)
|
||||||
|
name, _ := args["name"].(string)
|
||||||
|
parentToken, _ := args["parent_token"].(string)
|
||||||
|
pageToken, _ := args["page_token"].(string)
|
||||||
|
containerType, _ := args["container_type"].(string)
|
||||||
|
userIDType, _ := args["user_id_type"].(string)
|
||||||
|
dataBase64, _ := args["data_base64"].(string)
|
||||||
|
fileType, _ := args["file_type"].(string)
|
||||||
|
imageURL, _ := args["url"].(string)
|
||||||
|
resourceID, _ := args["resource_id"].(string)
|
||||||
|
|
||||||
|
pageSize := getIntArg(args, "page_size", 20)
|
||||||
|
seq := getIntArg(args, "seq", -1)
|
||||||
|
blockNum := getIntArg(args, "block_num", -1)
|
||||||
|
size := int64(getIntArg(args, "size", -1))
|
||||||
|
|
||||||
|
var (
|
||||||
|
result any
|
||||||
|
err error
|
||||||
|
)
|
||||||
|
switch action {
|
||||||
|
case "get_message":
|
||||||
|
if strings.TrimSpace(id) == "" { return ErrorResult("id is required") }
|
||||||
|
result, err = t.client.GetMessage(ctx, id)
|
||||||
|
case "list_messages":
|
||||||
|
if strings.TrimSpace(id) == "" { return ErrorResult("id is required") }
|
||||||
|
result, err = t.client.ListMessages(ctx, id, containerType, pageSize, pageToken)
|
||||||
|
case "reply_message":
|
||||||
|
if strings.TrimSpace(id) == "" || strings.TrimSpace(text) == "" { return ErrorResult("id and text are required") }
|
||||||
|
err = t.client.ReplyMessage(ctx, id, text)
|
||||||
|
result = map[string]any{"message_id": id, "text": text, "status": "ok"}
|
||||||
|
case "get_message_from_share_link":
|
||||||
|
if strings.TrimSpace(id) == "" { return ErrorResult("id is required") }
|
||||||
|
result, err = t.client.GetMessageFromShareLink(ctx, id)
|
||||||
|
case "get_user":
|
||||||
|
if strings.TrimSpace(id) == "" { return ErrorResult("id is required") }
|
||||||
|
result, err = t.client.GetUserInfo(ctx, id)
|
||||||
|
case "list_users":
|
||||||
|
result, err = t.client.ListUsers(ctx, pageSize, userIDType, pageToken)
|
||||||
|
case "get_user_id_by_email":
|
||||||
|
if strings.TrimSpace(id) == "" { return ErrorResult("id is required") }
|
||||||
|
var userID string
|
||||||
|
userID, err = t.client.GetUserIDByEmail(ctx, id)
|
||||||
|
result = map[string]any{"email": id, "user_id": userID}
|
||||||
|
case "get_user_id_by_mobile":
|
||||||
|
if strings.TrimSpace(id) == "" { return ErrorResult("id is required") }
|
||||||
|
var userID string
|
||||||
|
userID, err = t.client.GetUserIDByMobile(ctx, id)
|
||||||
|
result = map[string]any{"mobile": id, "user_id": userID}
|
||||||
|
case "get_user_id_by_phone":
|
||||||
|
if strings.TrimSpace(id) == "" { return ErrorResult("id is required") }
|
||||||
|
var userID string
|
||||||
|
userID, err = t.client.GetUserIDByMobile(ctx, id)
|
||||||
|
result = map[string]any{"phone": id, "user_id": userID}
|
||||||
|
case "create_group":
|
||||||
|
if strings.TrimSpace(name) == "" { return ErrorResult("name is required") }
|
||||||
|
result, err = t.client.CreateGroup(ctx, name)
|
||||||
|
case "get_group":
|
||||||
|
if strings.TrimSpace(id) == "" { return ErrorResult("id is required") }
|
||||||
|
result, err = t.client.GetGroupInfo(ctx, id)
|
||||||
|
case "list_group_members":
|
||||||
|
if strings.TrimSpace(id) == "" { return ErrorResult("id is required") }
|
||||||
|
result, err = t.client.ListGroupMembers(ctx, id, pageSize, pageToken)
|
||||||
|
case "list_groups":
|
||||||
|
result, err = t.client.ListGroups(ctx, pageSize, pageToken)
|
||||||
|
case "send_group_message":
|
||||||
|
if strings.TrimSpace(id) == "" || strings.TrimSpace(text) == "" { return ErrorResult("id and text are required") }
|
||||||
|
err = t.client.SendGroupMessage(ctx, id, text)
|
||||||
|
result = map[string]any{"chat_id": id, "text": text, "status": "ok"}
|
||||||
|
case "get_drive_root_folder":
|
||||||
|
result, err = t.client.GetDriveRootFolder(ctx)
|
||||||
|
case "get_drive_folder":
|
||||||
|
if strings.TrimSpace(id) == "" { return ErrorResult("id is required") }
|
||||||
|
result, err = t.client.GetDriveFolder(ctx, id)
|
||||||
|
case "get_drive_file":
|
||||||
|
if strings.TrimSpace(id) == "" { return ErrorResult("id is required") }
|
||||||
|
result, err = t.client.GetDriveFile(ctx, id)
|
||||||
|
case "list_drive_files":
|
||||||
|
result, err = t.client.ListDriveFiles(ctx, id, pageToken, pageSize)
|
||||||
|
case "download_drive_file", "download_image_to_bytes":
|
||||||
|
if strings.TrimSpace(id) == "" { return ErrorResult("id is required") }
|
||||||
|
result, err = t.client.DownloadDriveFile(ctx, id)
|
||||||
|
case "download_image_from_message":
|
||||||
|
if strings.TrimSpace(id) == "" { return ErrorResult("id is required") }
|
||||||
|
resolvedResourceID := strings.TrimSpace(resourceID)
|
||||||
|
message, getErr := t.client.GetMessage(ctx, id)
|
||||||
|
if getErr != nil {
|
||||||
|
err = getErr
|
||||||
|
break
|
||||||
|
}
|
||||||
|
messageMap := normalizeFeishuMessageToolValue(message)
|
||||||
|
if resolvedResourceID == "" {
|
||||||
|
resolvedResourceID = extractImageTokenFromMessageValue(messageMap)
|
||||||
|
if resolvedResourceID == "" {
|
||||||
|
return ErrorResult("resource_id is required when message content does not contain an image token")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result, err = t.client.DownloadDriveFile(ctx, resolvedResourceID)
|
||||||
|
if err == nil {
|
||||||
|
result = map[string]any{"message_id": id, "resource_id": resolvedResourceID, "download": result, "message": messageMap}
|
||||||
|
}
|
||||||
|
case "delete_drive_file":
|
||||||
|
if strings.TrimSpace(id) == "" { return ErrorResult("id is required") }
|
||||||
|
err = t.client.DeleteDriveFile(ctx, id)
|
||||||
|
result = map[string]any{"file_token": id, "status": "deleted"}
|
||||||
|
case "upload_drive_file":
|
||||||
|
if strings.TrimSpace(name) == "" || strings.TrimSpace(dataBase64) == "" { return ErrorResult("name and data_base64 are required") }
|
||||||
|
var data []byte
|
||||||
|
data, err = base64.StdEncoding.DecodeString(dataBase64)
|
||||||
|
if err == nil {
|
||||||
|
result, err = t.client.UploadDriveFile(ctx, parentToken, name, data)
|
||||||
|
}
|
||||||
|
case "initiate_multipart_upload":
|
||||||
|
if strings.TrimSpace(name) == "" || size < 0 { return ErrorResult("name and size are required") }
|
||||||
|
result, err = t.client.InitiateMultipartUpload(ctx, parentToken, name, size)
|
||||||
|
case "upload_multipart_chunk":
|
||||||
|
if strings.TrimSpace(id) == "" || seq < 0 || strings.TrimSpace(dataBase64) == "" { return ErrorResult("id, seq and data_base64 are required") }
|
||||||
|
var data []byte
|
||||||
|
data, err = base64.StdEncoding.DecodeString(dataBase64)
|
||||||
|
if err == nil {
|
||||||
|
err = t.client.UploadMultipartChunk(ctx, id, seq, data)
|
||||||
|
result = map[string]any{"upload_id": id, "seq": seq, "size": len(data), "status": "ok"}
|
||||||
|
}
|
||||||
|
case "complete_multipart_upload":
|
||||||
|
if strings.TrimSpace(id) == "" { return ErrorResult("id is required") }
|
||||||
|
if blockNum < 0 { return ErrorResult("block_num is required") }
|
||||||
|
result, err = t.client.CompleteMultipartUpload(ctx, id, blockNum)
|
||||||
|
case "send_image":
|
||||||
|
if strings.TrimSpace(id) == "" || strings.TrimSpace(dataBase64) == "" { return ErrorResult("id and data_base64 are required") }
|
||||||
|
if strings.TrimSpace(name) == "" { name = "image.bin" }
|
||||||
|
var data []byte
|
||||||
|
data, err = base64.StdEncoding.DecodeString(dataBase64)
|
||||||
|
if err == nil {
|
||||||
|
err = t.client.SendImageMessage(ctx, id, data, name)
|
||||||
|
result = map[string]any{"chat_id": id, "file_name": name, "size": len(data), "status": "ok"}
|
||||||
|
}
|
||||||
|
case "send_image_from_url":
|
||||||
|
if strings.TrimSpace(id) == "" || strings.TrimSpace(imageURL) == "" { return ErrorResult("id and url are required") }
|
||||||
|
if strings.TrimSpace(name) == "" { name = imageNameFromURL(imageURL) }
|
||||||
|
var data []byte
|
||||||
|
data, err = fetchURLBytes(ctx, imageURL)
|
||||||
|
if err == nil {
|
||||||
|
err = t.client.SendImageMessage(ctx, id, data, name)
|
||||||
|
result = map[string]any{"chat_id": id, "file_name": name, "source_url": imageURL, "size": len(data), "status": "ok"}
|
||||||
|
}
|
||||||
|
case "send_file":
|
||||||
|
if strings.TrimSpace(id) == "" || strings.TrimSpace(dataBase64) == "" { return ErrorResult("id and data_base64 are required") }
|
||||||
|
if strings.TrimSpace(name) == "" { return ErrorResult("name is required") }
|
||||||
|
if strings.TrimSpace(fileType) == "" { fileType = "stream" }
|
||||||
|
var data []byte
|
||||||
|
data, err = base64.StdEncoding.DecodeString(dataBase64)
|
||||||
|
if err == nil {
|
||||||
|
err = t.client.SendFileMessage(ctx, id, data, name, fileType)
|
||||||
|
result = map[string]any{"chat_id": id, "file_name": name, "file_type": fileType, "size": len(data), "status": "ok"}
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return ErrorResult("unsupported action")
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return ErrorResult(fmt.Sprintf("feishu remote query failed: %v", err)).WithError(err)
|
||||||
|
}
|
||||||
|
return structuredToolResult("feishu remote query result", result)
|
||||||
|
}
|
||||||
|
|
||||||
|
func getIntArg(args map[string]any, key string, fallback int) int {
|
||||||
|
switch v := args[key].(type) {
|
||||||
|
case int:
|
||||||
|
return v
|
||||||
|
case int32:
|
||||||
|
return int(v)
|
||||||
|
case int64:
|
||||||
|
return int(v)
|
||||||
|
case float64:
|
||||||
|
return int(v)
|
||||||
|
default:
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func imageNameFromURL(raw string) string {
|
||||||
|
trimmed := strings.TrimSpace(raw)
|
||||||
|
if trimmed == "" {
|
||||||
|
return "image.bin"
|
||||||
|
}
|
||||||
|
parts := strings.Split(trimmed, "/")
|
||||||
|
last := parts[len(parts)-1]
|
||||||
|
if last == "" || !strings.Contains(last, ".") {
|
||||||
|
return "image.bin"
|
||||||
|
}
|
||||||
|
if cut := strings.Index(last, "?"); cut >= 0 {
|
||||||
|
last = last[:cut]
|
||||||
|
}
|
||||||
|
if last == "" {
|
||||||
|
return "image.bin"
|
||||||
|
}
|
||||||
|
return last
|
||||||
|
}
|
||||||
|
|
||||||
|
func fetchURLBytes(ctx context.Context, raw string) ([]byte, error) {
|
||||||
|
if ctx == nil {
|
||||||
|
ctx = context.Background()
|
||||||
|
}
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, raw, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||||
|
return nil, fmt.Errorf("unexpected status: %s", resp.Status)
|
||||||
|
}
|
||||||
|
return io.ReadAll(resp.Body)
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeFeishuMessageToolValue(v any) map[string]any {
|
||||||
|
if m, ok := v.(map[string]any); ok {
|
||||||
|
if nested, ok := m["message"].(map[string]any); ok {
|
||||||
|
return nested
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
return map[string]any{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractImageTokenFromMessageValue(message map[string]any) string {
|
||||||
|
if len(message) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if parsed, ok := message["parsed"].(map[string]any); ok {
|
||||||
|
if token := extractImageTokenFromContentValue(parsed["content"]); token != "" {
|
||||||
|
return token
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if body, ok := message["body"].(map[string]any); ok {
|
||||||
|
if raw, ok := body["content"].(string); ok && strings.TrimSpace(raw) != "" {
|
||||||
|
var decoded any
|
||||||
|
if err := json.Unmarshal([]byte(raw), &decoded); err == nil {
|
||||||
|
if token := extractImageTokenFromContentValue(decoded); token != "" {
|
||||||
|
return token
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return extractImageTokenFromContentValue(message["content"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractImageTokenFromContentValue(v any) string {
|
||||||
|
switch vv := v.(type) {
|
||||||
|
case map[string]any:
|
||||||
|
if token, ok := vv["image_token"].(string); ok && strings.TrimSpace(token) != "" {
|
||||||
|
return token
|
||||||
|
}
|
||||||
|
if token, ok := vv["image_key"].(string); ok && strings.TrimSpace(token) != "" {
|
||||||
|
return token
|
||||||
|
}
|
||||||
|
case []any:
|
||||||
|
for _, item := range vv {
|
||||||
|
if token := extractImageTokenFromContentValue(item); token != "" {
|
||||||
|
return token
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
315
pkg/tools/feishu_adapter.go
Normal file
315
pkg/tools/feishu_adapter.go
Normal file
|
|
@ -0,0 +1,315 @@
|
||||||
|
package tools
|
||||||
|
|
||||||
|
import "context"
|
||||||
|
|
||||||
|
type FeishuMessageGetter interface {
|
||||||
|
GetMessage(ctx context.Context, messageID string) (any, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type FeishuMessageLister interface {
|
||||||
|
ListMessages(ctx context.Context, containerID, containerType string, pageSize int, pageToken string) (any, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type FeishuMessageReplier interface {
|
||||||
|
ReplyMessage(ctx context.Context, messageID, text string) error
|
||||||
|
}
|
||||||
|
|
||||||
|
type FeishuShareLinkResolver interface {
|
||||||
|
GetMessageFromShareLink(ctx context.Context, shareLink string) (any, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type FeishuUserGetter interface {
|
||||||
|
GetUserInfo(ctx context.Context, userID string) (any, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type FeishuUserLister interface {
|
||||||
|
ListUsers(ctx context.Context, pageSize int, userIDType, pageToken string) (any, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type FeishuUserLookup interface {
|
||||||
|
GetUserIDByEmail(ctx context.Context, email string) (string, error)
|
||||||
|
GetUserIDByMobile(ctx context.Context, mobile string) (string, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type FeishuGroupCreator interface {
|
||||||
|
CreateGroup(ctx context.Context, name string) (any, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type FeishuGroupGetter interface {
|
||||||
|
GetGroupInfo(ctx context.Context, chatID string) (any, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type FeishuGroupMemberLister interface {
|
||||||
|
ListGroupMembers(ctx context.Context, chatID string, pageSize int, pageToken string) (any, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type FeishuGroupLister interface {
|
||||||
|
ListGroups(ctx context.Context, pageSize int, pageToken string) (any, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type FeishuGroupMessageSender interface {
|
||||||
|
SendGroupMessage(ctx context.Context, chatID, text string) error
|
||||||
|
}
|
||||||
|
|
||||||
|
type FeishuDriveRootGetter interface {
|
||||||
|
GetDriveRootFolder(ctx context.Context) (any, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type FeishuDriveFolderGetter interface {
|
||||||
|
GetDriveFolder(ctx context.Context, folderToken string) (any, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type FeishuDriveFileGetter interface {
|
||||||
|
GetDriveFile(ctx context.Context, fileToken string) (any, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type FeishuDriveFileLister interface {
|
||||||
|
ListDriveFiles(ctx context.Context, folderToken, pageToken string, pageSize int) (any, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type FeishuDriveFileDownloader interface {
|
||||||
|
DownloadDriveFile(ctx context.Context, fileToken string) (any, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type FeishuDriveFileDeleter interface {
|
||||||
|
DeleteDriveFile(ctx context.Context, fileToken string) error
|
||||||
|
}
|
||||||
|
|
||||||
|
type FeishuDriveFileUploader interface {
|
||||||
|
UploadDriveFile(ctx context.Context, parentToken, name string, data []byte) (any, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type FeishuMultipartInitiator interface {
|
||||||
|
InitiateMultipartUpload(ctx context.Context, parentToken, name string, size int64) (any, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type FeishuMultipartChunkUploader interface {
|
||||||
|
UploadMultipartChunk(ctx context.Context, uploadID string, seq int, data []byte) error
|
||||||
|
}
|
||||||
|
|
||||||
|
type FeishuMultipartCompleter interface {
|
||||||
|
CompleteMultipartUpload(ctx context.Context, uploadID string, blockNum int) (any, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type FeishuImageSender interface {
|
||||||
|
SendImageMessage(ctx context.Context, chatID string, data []byte, fileName string) error
|
||||||
|
}
|
||||||
|
|
||||||
|
type FeishuFileSender interface {
|
||||||
|
SendFileMessage(ctx context.Context, chatID string, data []byte, fileName, fileType string) error
|
||||||
|
}
|
||||||
|
|
||||||
|
type FeishuChannelAdapter struct {
|
||||||
|
messageGetter FeishuMessageGetter
|
||||||
|
messageLister FeishuMessageLister
|
||||||
|
messageReplier FeishuMessageReplier
|
||||||
|
shareLinkResolver FeishuShareLinkResolver
|
||||||
|
userGetter FeishuUserGetter
|
||||||
|
userLister FeishuUserLister
|
||||||
|
userLookup FeishuUserLookup
|
||||||
|
groupCreator FeishuGroupCreator
|
||||||
|
groupGetter FeishuGroupGetter
|
||||||
|
groupMemberLister FeishuGroupMemberLister
|
||||||
|
groupLister FeishuGroupLister
|
||||||
|
groupMessageSender FeishuGroupMessageSender
|
||||||
|
driveRootGetter FeishuDriveRootGetter
|
||||||
|
driveFolderGetter FeishuDriveFolderGetter
|
||||||
|
driveGetter FeishuDriveFileGetter
|
||||||
|
driveLister FeishuDriveFileLister
|
||||||
|
driveDownloader FeishuDriveFileDownloader
|
||||||
|
driveDeleter FeishuDriveFileDeleter
|
||||||
|
driveUploader FeishuDriveFileUploader
|
||||||
|
multipartInitiator FeishuMultipartInitiator
|
||||||
|
multipartUploader FeishuMultipartChunkUploader
|
||||||
|
multipartCompleter FeishuMultipartCompleter
|
||||||
|
imageSender FeishuImageSender
|
||||||
|
fileSender FeishuFileSender
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewFeishuChannelAdapter(target any) *FeishuChannelAdapter {
|
||||||
|
adapter := &FeishuChannelAdapter{}
|
||||||
|
if v, ok := target.(FeishuMessageGetter); ok { adapter.messageGetter = v }
|
||||||
|
if v, ok := target.(FeishuMessageLister); ok { adapter.messageLister = v }
|
||||||
|
if v, ok := target.(FeishuMessageReplier); ok { adapter.messageReplier = v }
|
||||||
|
if v, ok := target.(FeishuShareLinkResolver); ok { adapter.shareLinkResolver = v }
|
||||||
|
if v, ok := target.(FeishuUserGetter); ok { adapter.userGetter = v }
|
||||||
|
if v, ok := target.(FeishuUserLister); ok { adapter.userLister = v }
|
||||||
|
if v, ok := target.(FeishuUserLookup); ok { adapter.userLookup = v }
|
||||||
|
if v, ok := target.(FeishuGroupCreator); ok { adapter.groupCreator = v }
|
||||||
|
if v, ok := target.(FeishuGroupGetter); ok { adapter.groupGetter = v }
|
||||||
|
if v, ok := target.(FeishuGroupMemberLister); ok { adapter.groupMemberLister = v }
|
||||||
|
if v, ok := target.(FeishuGroupLister); ok { adapter.groupLister = v }
|
||||||
|
if v, ok := target.(FeishuGroupMessageSender); ok { adapter.groupMessageSender = v }
|
||||||
|
if v, ok := target.(FeishuDriveRootGetter); ok { adapter.driveRootGetter = v }
|
||||||
|
if v, ok := target.(FeishuDriveFolderGetter); ok { adapter.driveFolderGetter = v }
|
||||||
|
if v, ok := target.(FeishuDriveFileGetter); ok { adapter.driveGetter = v }
|
||||||
|
if v, ok := target.(FeishuDriveFileLister); ok { adapter.driveLister = v }
|
||||||
|
if v, ok := target.(FeishuDriveFileDownloader); ok { adapter.driveDownloader = v }
|
||||||
|
if v, ok := target.(FeishuDriveFileDeleter); ok { adapter.driveDeleter = v }
|
||||||
|
if v, ok := target.(FeishuDriveFileUploader); ok { adapter.driveUploader = v }
|
||||||
|
if v, ok := target.(FeishuMultipartInitiator); ok { adapter.multipartInitiator = v }
|
||||||
|
if v, ok := target.(FeishuMultipartChunkUploader); ok { adapter.multipartUploader = v }
|
||||||
|
if v, ok := target.(FeishuMultipartCompleter); ok { adapter.multipartCompleter = v }
|
||||||
|
if v, ok := target.(FeishuImageSender); ok { adapter.imageSender = v }
|
||||||
|
if v, ok := target.(FeishuFileSender); ok { adapter.fileSender = v }
|
||||||
|
return adapter
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *FeishuChannelAdapter) GetMessage(ctx context.Context, messageID string) (any, error) {
|
||||||
|
if a == nil || a.messageGetter == nil { return nil, errFeishuAdapterNotReady("GetMessage") }
|
||||||
|
return a.messageGetter.GetMessage(ctx, messageID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *FeishuChannelAdapter) ListMessages(ctx context.Context, containerID, containerType string, pageSize int, pageToken string) (any, error) {
|
||||||
|
if a == nil || a.messageLister == nil { return nil, errFeishuAdapterNotReady("ListMessages") }
|
||||||
|
return a.messageLister.ListMessages(ctx, containerID, containerType, pageSize, pageToken)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *FeishuChannelAdapter) ReplyMessage(ctx context.Context, messageID, text string) error {
|
||||||
|
if a == nil || a.messageReplier == nil { return errFeishuAdapterNotReady("ReplyMessage") }
|
||||||
|
return a.messageReplier.ReplyMessage(ctx, messageID, text)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *FeishuChannelAdapter) GetMessageFromShareLink(ctx context.Context, shareLink string) (any, error) {
|
||||||
|
if a == nil || a.shareLinkResolver == nil { return nil, errFeishuAdapterNotReady("GetMessageFromShareLink") }
|
||||||
|
return a.shareLinkResolver.GetMessageFromShareLink(ctx, shareLink)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *FeishuChannelAdapter) GetUserInfo(ctx context.Context, userID string) (any, error) {
|
||||||
|
if a == nil || a.userGetter == nil { return nil, errFeishuAdapterNotReady("GetUserInfo") }
|
||||||
|
return a.userGetter.GetUserInfo(ctx, userID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *FeishuChannelAdapter) ListUsers(ctx context.Context, pageSize int, userIDType, pageToken string) (any, error) {
|
||||||
|
if a == nil || a.userLister == nil { return nil, errFeishuAdapterNotReady("ListUsers") }
|
||||||
|
return a.userLister.ListUsers(ctx, pageSize, userIDType, pageToken)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *FeishuChannelAdapter) GetUserIDByEmail(ctx context.Context, email string) (string, error) {
|
||||||
|
if a == nil || a.userLookup == nil { return "", errFeishuAdapterNotReady("GetUserIDByEmail") }
|
||||||
|
return a.userLookup.GetUserIDByEmail(ctx, email)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *FeishuChannelAdapter) GetUserIDByMobile(ctx context.Context, mobile string) (string, error) {
|
||||||
|
if a == nil || a.userLookup == nil { return "", errFeishuAdapterNotReady("GetUserIDByMobile") }
|
||||||
|
return a.userLookup.GetUserIDByMobile(ctx, mobile)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *FeishuChannelAdapter) CreateGroup(ctx context.Context, name string) (any, error) {
|
||||||
|
if a == nil || a.groupCreator == nil { return nil, errFeishuAdapterNotReady("CreateGroup") }
|
||||||
|
return a.groupCreator.CreateGroup(ctx, name)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *FeishuChannelAdapter) GetGroupInfo(ctx context.Context, chatID string) (any, error) {
|
||||||
|
if a == nil || a.groupGetter == nil { return nil, errFeishuAdapterNotReady("GetGroupInfo") }
|
||||||
|
return a.groupGetter.GetGroupInfo(ctx, chatID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *FeishuChannelAdapter) ListGroupMembers(ctx context.Context, chatID string, pageSize int, pageToken string) (any, error) {
|
||||||
|
if a == nil || a.groupMemberLister == nil { return nil, errFeishuAdapterNotReady("ListGroupMembers") }
|
||||||
|
return a.groupMemberLister.ListGroupMembers(ctx, chatID, pageSize, pageToken)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *FeishuChannelAdapter) ListGroups(ctx context.Context, pageSize int, pageToken string) (any, error) {
|
||||||
|
if a == nil || a.groupLister == nil { return nil, errFeishuAdapterNotReady("ListGroups") }
|
||||||
|
return a.groupLister.ListGroups(ctx, pageSize, pageToken)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *FeishuChannelAdapter) SendGroupMessage(ctx context.Context, chatID, text string) error {
|
||||||
|
if a == nil || a.groupMessageSender == nil { return errFeishuAdapterNotReady("SendGroupMessage") }
|
||||||
|
return a.groupMessageSender.SendGroupMessage(ctx, chatID, text)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *FeishuChannelAdapter) GetDriveRootFolder(ctx context.Context) (any, error) {
|
||||||
|
if a == nil || a.driveRootGetter == nil { return nil, errFeishuAdapterNotReady("GetDriveRootFolder") }
|
||||||
|
return a.driveRootGetter.GetDriveRootFolder(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *FeishuChannelAdapter) GetDriveFolder(ctx context.Context, folderToken string) (any, error) {
|
||||||
|
if a == nil || a.driveFolderGetter == nil { return nil, errFeishuAdapterNotReady("GetDriveFolder") }
|
||||||
|
return a.driveFolderGetter.GetDriveFolder(ctx, folderToken)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *FeishuChannelAdapter) GetDriveFile(ctx context.Context, fileToken string) (any, error) {
|
||||||
|
if a == nil || a.driveGetter == nil { return nil, errFeishuAdapterNotReady("GetDriveFile") }
|
||||||
|
return a.driveGetter.GetDriveFile(ctx, fileToken)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *FeishuChannelAdapter) ListDriveFiles(ctx context.Context, folderToken, pageToken string, pageSize int) (any, error) {
|
||||||
|
if a == nil || a.driveLister == nil { return nil, errFeishuAdapterNotReady("ListDriveFiles") }
|
||||||
|
return a.driveLister.ListDriveFiles(ctx, folderToken, pageToken, pageSize)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *FeishuChannelAdapter) DownloadDriveFile(ctx context.Context, fileToken string) (any, error) {
|
||||||
|
if a == nil || a.driveDownloader == nil { return nil, errFeishuAdapterNotReady("DownloadDriveFile") }
|
||||||
|
return a.driveDownloader.DownloadDriveFile(ctx, fileToken)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *FeishuChannelAdapter) DeleteDriveFile(ctx context.Context, fileToken string) error {
|
||||||
|
if a == nil || a.driveDeleter == nil { return errFeishuAdapterNotReady("DeleteDriveFile") }
|
||||||
|
return a.driveDeleter.DeleteDriveFile(ctx, fileToken)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *FeishuChannelAdapter) UploadDriveFile(ctx context.Context, parentToken, name string, data []byte) (any, error) {
|
||||||
|
if a == nil || a.driveUploader == nil { return nil, errFeishuAdapterNotReady("UploadDriveFile") }
|
||||||
|
return a.driveUploader.UploadDriveFile(ctx, parentToken, name, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *FeishuChannelAdapter) InitiateMultipartUpload(ctx context.Context, parentToken, name string, size int64) (any, error) {
|
||||||
|
if a == nil || a.multipartInitiator == nil { return nil, errFeishuAdapterNotReady("InitiateMultipartUpload") }
|
||||||
|
return a.multipartInitiator.InitiateMultipartUpload(ctx, parentToken, name, size)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *FeishuChannelAdapter) UploadMultipartChunk(ctx context.Context, uploadID string, seq int, data []byte) error {
|
||||||
|
if a == nil || a.multipartUploader == nil { return errFeishuAdapterNotReady("UploadMultipartChunk") }
|
||||||
|
return a.multipartUploader.UploadMultipartChunk(ctx, uploadID, seq, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *FeishuChannelAdapter) CompleteMultipartUpload(ctx context.Context, uploadID string, blockNum int) (any, error) {
|
||||||
|
if a == nil || a.multipartCompleter == nil { return nil, errFeishuAdapterNotReady("CompleteMultipartUpload") }
|
||||||
|
return a.multipartCompleter.CompleteMultipartUpload(ctx, uploadID, blockNum)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *FeishuChannelAdapter) SendImageMessage(ctx context.Context, chatID string, data []byte, fileName string) error {
|
||||||
|
if a == nil || a.imageSender == nil { return errFeishuAdapterNotReady("SendImageMessage") }
|
||||||
|
return a.imageSender.SendImageMessage(ctx, chatID, data, fileName)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *FeishuChannelAdapter) SendFileMessage(ctx context.Context, chatID string, data []byte, fileName, fileType string) error {
|
||||||
|
if a == nil || a.fileSender == nil { return errFeishuAdapterNotReady("SendFileMessage") }
|
||||||
|
return a.fileSender.SendFileMessage(ctx, chatID, data, fileName, fileType)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *FeishuChannelAdapter) Ready() bool {
|
||||||
|
return a != nil &&
|
||||||
|
a.messageGetter != nil &&
|
||||||
|
a.messageLister != nil &&
|
||||||
|
a.messageReplier != nil &&
|
||||||
|
a.shareLinkResolver != nil &&
|
||||||
|
a.userGetter != nil &&
|
||||||
|
a.userLister != nil &&
|
||||||
|
a.userLookup != nil &&
|
||||||
|
a.groupCreator != nil &&
|
||||||
|
a.groupGetter != nil &&
|
||||||
|
a.groupMemberLister != nil &&
|
||||||
|
a.groupLister != nil &&
|
||||||
|
a.groupMessageSender != nil &&
|
||||||
|
a.driveRootGetter != nil &&
|
||||||
|
a.driveFolderGetter != nil &&
|
||||||
|
a.driveGetter != nil &&
|
||||||
|
a.driveLister != nil &&
|
||||||
|
a.driveDownloader != nil &&
|
||||||
|
a.driveDeleter != nil &&
|
||||||
|
a.driveUploader != nil &&
|
||||||
|
a.multipartInitiator != nil &&
|
||||||
|
a.multipartUploader != nil &&
|
||||||
|
a.multipartCompleter != nil &&
|
||||||
|
a.imageSender != nil &&
|
||||||
|
a.fileSender != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type feishuAdapterError struct{ method string }
|
||||||
|
|
||||||
|
func (e feishuAdapterError) Error() string { return "feishu adapter not ready for method: " + e.method }
|
||||||
|
|
||||||
|
func errFeishuAdapterNotReady(method string) error { return feishuAdapterError{method: method} }
|
||||||
23
pkg/tools/feishu_registry.go
Normal file
23
pkg/tools/feishu_registry.go
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
package tools
|
||||||
|
|
||||||
|
import "github.com/sipeed/picoclaw/pkg/config"
|
||||||
|
|
||||||
|
// RegisterFeishuTools registers Feishu-related local tools that do not require
|
||||||
|
// a live channel instance or remote API injection.
|
||||||
|
func RegisterFeishuTools(registry *ToolRegistry, cfg *config.Config) {
|
||||||
|
RegisterFeishuToolsWithClient(registry, cfg, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RegisterFeishuToolsWithClient registers local Feishu tools and, when provided,
|
||||||
|
// also registers remote query tools backed by an injected client.
|
||||||
|
func RegisterFeishuToolsWithClient(registry *ToolRegistry, cfg *config.Config, client FeishuRemoteClient) {
|
||||||
|
if registry == nil || cfg == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if cfg.Tools.IsToolEnabled("feishu_parse") {
|
||||||
|
registry.Register(NewFeishuParseTool())
|
||||||
|
}
|
||||||
|
if client != nil && cfg.Tools.IsToolEnabled("feishu_remote") {
|
||||||
|
registry.Register(NewFeishuRemoteTool(client))
|
||||||
|
}
|
||||||
|
}
|
||||||
13
pkg/tools/feishu_registry_test.go
Normal file
13
pkg/tools/feishu_registry_test.go
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
package tools
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRegisterFeishuToolsWithNilInputs(t *testing.T) {
|
||||||
|
RegisterFeishuTools(nil, &config.Config{})
|
||||||
|
RegisterFeishuTools(NewToolRegistry(), nil)
|
||||||
|
RegisterFeishuToolsWithClient(nil, &config.Config{}, &mockFeishuRemoteClient{})
|
||||||
|
}
|
||||||
385
pkg/tools/feishu_test.go
Normal file
385
pkg/tools/feishu_test.go
Normal file
|
|
@ -0,0 +1,385 @@
|
||||||
|
package tools
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/base64"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
type mockFeishuRemoteClient struct{}
|
||||||
|
|
||||||
|
func (m *mockFeishuRemoteClient) GetMessage(ctx context.Context, messageID string) (any, error) {
|
||||||
|
switch messageID {
|
||||||
|
case "om_with_image":
|
||||||
|
return map[string]any{
|
||||||
|
"message_id": messageID,
|
||||||
|
"body": map[string]any{"content": `{"image_key":"img_from_message"}`},
|
||||||
|
}, nil
|
||||||
|
case "om_without_image":
|
||||||
|
return map[string]any{
|
||||||
|
"message_id": messageID,
|
||||||
|
"body": map[string]any{"content": `{"text":"hello"}`},
|
||||||
|
}, nil
|
||||||
|
default:
|
||||||
|
return map[string]any{"message_id": messageID}, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
func (m *mockFeishuRemoteClient) ListMessages(ctx context.Context, containerID, containerType string, pageSize int, pageToken string) (any, error) {
|
||||||
|
return map[string]any{"container_id": containerID, "container_type": containerType, "page_size": pageSize, "page_token": pageToken}, nil
|
||||||
|
}
|
||||||
|
func (m *mockFeishuRemoteClient) ReplyMessage(ctx context.Context, messageID, text string) error { return nil }
|
||||||
|
func (m *mockFeishuRemoteClient) GetMessageFromShareLink(ctx context.Context, shareLink string) (any, error) {
|
||||||
|
return map[string]any{"share_link": shareLink}, nil
|
||||||
|
}
|
||||||
|
func (m *mockFeishuRemoteClient) GetUserInfo(ctx context.Context, userID string) (any, error) {
|
||||||
|
return map[string]any{"user_id": userID}, nil
|
||||||
|
}
|
||||||
|
func (m *mockFeishuRemoteClient) ListUsers(ctx context.Context, pageSize int, userIDType, pageToken string) (any, error) {
|
||||||
|
return map[string]any{"page_size": pageSize, "user_id_type": userIDType, "page_token": pageToken}, nil
|
||||||
|
}
|
||||||
|
func (m *mockFeishuRemoteClient) GetUserIDByEmail(ctx context.Context, email string) (string, error) {
|
||||||
|
return "ou_email", nil
|
||||||
|
}
|
||||||
|
func (m *mockFeishuRemoteClient) GetUserIDByMobile(ctx context.Context, mobile string) (string, error) {
|
||||||
|
return "ou_mobile", nil
|
||||||
|
}
|
||||||
|
func (m *mockFeishuRemoteClient) CreateGroup(ctx context.Context, name string) (any, error) {
|
||||||
|
return map[string]any{"name": name}, nil
|
||||||
|
}
|
||||||
|
func (m *mockFeishuRemoteClient) GetGroupInfo(ctx context.Context, chatID string) (any, error) {
|
||||||
|
return map[string]any{"chat_id": chatID}, nil
|
||||||
|
}
|
||||||
|
func (m *mockFeishuRemoteClient) ListGroupMembers(ctx context.Context, chatID string, pageSize int, pageToken string) (any, error) {
|
||||||
|
return map[string]any{"chat_id": chatID, "page_size": pageSize, "page_token": pageToken}, nil
|
||||||
|
}
|
||||||
|
func (m *mockFeishuRemoteClient) ListGroups(ctx context.Context, pageSize int, pageToken string) (any, error) {
|
||||||
|
return map[string]any{"page_size": pageSize, "page_token": pageToken}, nil
|
||||||
|
}
|
||||||
|
func (m *mockFeishuRemoteClient) SendGroupMessage(ctx context.Context, chatID, text string) error { return nil }
|
||||||
|
func (m *mockFeishuRemoteClient) GetDriveRootFolder(ctx context.Context) (any, error) {
|
||||||
|
return map[string]any{"folder_token": "root"}, nil
|
||||||
|
}
|
||||||
|
func (m *mockFeishuRemoteClient) GetDriveFolder(ctx context.Context, folderToken string) (any, error) {
|
||||||
|
return map[string]any{"folder_token": folderToken}, nil
|
||||||
|
}
|
||||||
|
func (m *mockFeishuRemoteClient) GetDriveFile(ctx context.Context, fileToken string) (any, error) {
|
||||||
|
return map[string]any{"file_token": fileToken}, nil
|
||||||
|
}
|
||||||
|
func (m *mockFeishuRemoteClient) ListDriveFiles(ctx context.Context, folderToken, pageToken string, pageSize int) (any, error) {
|
||||||
|
return map[string]any{"folder_token": folderToken, "page_token": pageToken, "page_size": pageSize}, nil
|
||||||
|
}
|
||||||
|
func (m *mockFeishuRemoteClient) DownloadDriveFile(ctx context.Context, fileToken string) (any, error) {
|
||||||
|
return map[string]any{"name": "demo.txt", "content_type": "text/plain", "data_base64": base64.StdEncoding.EncodeToString([]byte("demo"))}, nil
|
||||||
|
}
|
||||||
|
func (m *mockFeishuRemoteClient) DeleteDriveFile(ctx context.Context, fileToken string) error { return nil }
|
||||||
|
func (m *mockFeishuRemoteClient) UploadDriveFile(ctx context.Context, parentToken, name string, data []byte) (any, error) {
|
||||||
|
return map[string]any{"parent_token": parentToken, "name": name, "size": len(data)}, nil
|
||||||
|
}
|
||||||
|
func (m *mockFeishuRemoteClient) InitiateMultipartUpload(ctx context.Context, parentToken, name string, size int64) (any, error) {
|
||||||
|
return map[string]any{"upload_id": "upload_1", "parent_token": parentToken, "name": name, "size": size}, nil
|
||||||
|
}
|
||||||
|
func (m *mockFeishuRemoteClient) UploadMultipartChunk(ctx context.Context, uploadID string, seq int, data []byte) error { return nil }
|
||||||
|
func (m *mockFeishuRemoteClient) CompleteMultipartUpload(ctx context.Context, uploadID string, blockNum int) (any, error) {
|
||||||
|
return map[string]any{"upload_id": uploadID, "block_num": blockNum}, nil
|
||||||
|
}
|
||||||
|
func (m *mockFeishuRemoteClient) SendImageMessage(ctx context.Context, chatID string, data []byte, fileName string) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
func (m *mockFeishuRemoteClient) SendFileMessage(ctx context.Context, chatID string, data []byte, fileName, fileType string) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type errFeishuRemoteClient struct{}
|
||||||
|
|
||||||
|
func (e *errFeishuRemoteClient) GetMessage(ctx context.Context, messageID string) (any, error) { return nil, fmt.Errorf("boom") }
|
||||||
|
func (e *errFeishuRemoteClient) ListMessages(ctx context.Context, containerID, containerType string, pageSize int, pageToken string) (any, error) {
|
||||||
|
return nil, fmt.Errorf("boom")
|
||||||
|
}
|
||||||
|
func (e *errFeishuRemoteClient) ReplyMessage(ctx context.Context, messageID, text string) error { return fmt.Errorf("boom") }
|
||||||
|
func (e *errFeishuRemoteClient) GetMessageFromShareLink(ctx context.Context, shareLink string) (any, error) { return nil, fmt.Errorf("boom") }
|
||||||
|
func (e *errFeishuRemoteClient) GetUserInfo(ctx context.Context, userID string) (any, error) { return nil, fmt.Errorf("boom") }
|
||||||
|
func (e *errFeishuRemoteClient) ListUsers(ctx context.Context, pageSize int, userIDType, pageToken string) (any, error) {
|
||||||
|
return nil, fmt.Errorf("boom")
|
||||||
|
}
|
||||||
|
func (e *errFeishuRemoteClient) GetUserIDByEmail(ctx context.Context, email string) (string, error) { return "", fmt.Errorf("boom") }
|
||||||
|
func (e *errFeishuRemoteClient) GetUserIDByMobile(ctx context.Context, mobile string) (string, error) { return "", fmt.Errorf("boom") }
|
||||||
|
func (e *errFeishuRemoteClient) CreateGroup(ctx context.Context, name string) (any, error) { return nil, fmt.Errorf("boom") }
|
||||||
|
func (e *errFeishuRemoteClient) GetGroupInfo(ctx context.Context, chatID string) (any, error) { return nil, fmt.Errorf("boom") }
|
||||||
|
func (e *errFeishuRemoteClient) ListGroupMembers(ctx context.Context, chatID string, pageSize int, pageToken string) (any, error) {
|
||||||
|
return nil, fmt.Errorf("boom")
|
||||||
|
}
|
||||||
|
func (e *errFeishuRemoteClient) ListGroups(ctx context.Context, pageSize int, pageToken string) (any, error) { return nil, fmt.Errorf("boom") }
|
||||||
|
func (e *errFeishuRemoteClient) SendGroupMessage(ctx context.Context, chatID, text string) error { return fmt.Errorf("boom") }
|
||||||
|
func (e *errFeishuRemoteClient) GetDriveRootFolder(ctx context.Context) (any, error) { return nil, fmt.Errorf("boom") }
|
||||||
|
func (e *errFeishuRemoteClient) GetDriveFolder(ctx context.Context, folderToken string) (any, error) { return nil, fmt.Errorf("boom") }
|
||||||
|
func (e *errFeishuRemoteClient) GetDriveFile(ctx context.Context, fileToken string) (any, error) { return nil, fmt.Errorf("boom") }
|
||||||
|
func (e *errFeishuRemoteClient) ListDriveFiles(ctx context.Context, folderToken, pageToken string, pageSize int) (any, error) {
|
||||||
|
return nil, fmt.Errorf("boom")
|
||||||
|
}
|
||||||
|
func (e *errFeishuRemoteClient) DownloadDriveFile(ctx context.Context, fileToken string) (any, error) { return nil, fmt.Errorf("boom") }
|
||||||
|
func (e *errFeishuRemoteClient) DeleteDriveFile(ctx context.Context, fileToken string) error { return fmt.Errorf("boom") }
|
||||||
|
func (e *errFeishuRemoteClient) UploadDriveFile(ctx context.Context, parentToken, name string, data []byte) (any, error) {
|
||||||
|
return nil, fmt.Errorf("boom")
|
||||||
|
}
|
||||||
|
func (e *errFeishuRemoteClient) InitiateMultipartUpload(ctx context.Context, parentToken, name string, size int64) (any, error) {
|
||||||
|
return nil, fmt.Errorf("boom")
|
||||||
|
}
|
||||||
|
func (e *errFeishuRemoteClient) UploadMultipartChunk(ctx context.Context, uploadID string, seq int, data []byte) error {
|
||||||
|
return fmt.Errorf("boom")
|
||||||
|
}
|
||||||
|
func (e *errFeishuRemoteClient) CompleteMultipartUpload(ctx context.Context, uploadID string, blockNum int) (any, error) {
|
||||||
|
return nil, fmt.Errorf("boom")
|
||||||
|
}
|
||||||
|
func (e *errFeishuRemoteClient) SendImageMessage(ctx context.Context, chatID string, data []byte, fileName string) error {
|
||||||
|
return fmt.Errorf("boom")
|
||||||
|
}
|
||||||
|
func (e *errFeishuRemoteClient) SendFileMessage(ctx context.Context, chatID string, data []byte, fileName, fileType string) error {
|
||||||
|
return fmt.Errorf("boom")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFeishuParseToolShareLink(t *testing.T) {
|
||||||
|
tool := NewFeishuParseTool()
|
||||||
|
result := tool.Execute(context.Background(), map[string]any{"mode": "share_link", "content": "https://applink.feishu.cn/client/message/link/open?token=om_abc%3D%3D&foo=bar"})
|
||||||
|
if result.IsError || !strings.Contains(result.ForLLM, "om_abc%3D%3D") {
|
||||||
|
t.Fatalf("unexpected result: %s", result.ForLLM)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFeishuParseToolMessageContent(t *testing.T) {
|
||||||
|
tool := NewFeishuParseTool()
|
||||||
|
result := tool.Execute(context.Background(), map[string]any{"mode": "message_content", "content": `{"text":"hello"}`})
|
||||||
|
if result.IsError || !strings.Contains(result.ForLLM, "hello") {
|
||||||
|
t.Fatalf("unexpected result: %s", result.ForLLM)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFeishuParseToolCard(t *testing.T) {
|
||||||
|
tool := NewFeishuParseTool()
|
||||||
|
result := tool.Execute(context.Background(), map[string]any{"mode": "card", "content": `{"header":{"title":{"content":"demo"}},"elements":[{"tag":"div","text":{"content":"body"}}]}`})
|
||||||
|
if result.IsError || !strings.Contains(result.ForLLM, "demo") || !strings.Contains(result.ForLLM, "body") {
|
||||||
|
t.Fatalf("unexpected result: %s", result.ForLLM)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFeishuParseToolCardWithPythonStyleContent(t *testing.T) {
|
||||||
|
tool := NewFeishuParseTool()
|
||||||
|
result := tool.Execute(context.Background(), map[string]any{"mode": "card", "content": `{"title":"demo title","content":[{"tag":"text","text":"plain body"},{"tag":"img","image_key":"img_v2_key"},{"tag":"action","actions":[{"text":{"content":"Click"},"type":"primary"}]}]}`})
|
||||||
|
if result.IsError || !strings.Contains(result.ForLLM, "demo title") || !strings.Contains(result.ForLLM, "plain body") || !strings.Contains(result.ForLLM, "img_v2_key") || !strings.Contains(result.ForLLM, "Click") {
|
||||||
|
t.Fatalf("unexpected result: %s", result.ForLLM)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRegisterFeishuToolsEnabled(t *testing.T) {
|
||||||
|
registry := NewToolRegistry()
|
||||||
|
cfg := &config.Config{}
|
||||||
|
RegisterFeishuTools(registry, cfg)
|
||||||
|
if _, ok := registry.Get("feishu_parse"); !ok {
|
||||||
|
t.Fatal("expected feishu_parse to be registered by default")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRegisterFeishuToolsDisabled(t *testing.T) {
|
||||||
|
registry := NewToolRegistry()
|
||||||
|
cfg := &config.Config{}
|
||||||
|
RegisterFeishuTools(registry, cfg)
|
||||||
|
if _, ok := registry.Get("feishu_parse"); !ok {
|
||||||
|
t.Fatal("expected feishu_parse to be registered")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFeishuRemoteToolGetMessage(t *testing.T) {
|
||||||
|
tool := NewFeishuRemoteTool(&mockFeishuRemoteClient{})
|
||||||
|
result := tool.Execute(context.Background(), map[string]any{"action": "get_message", "id": "om_123"})
|
||||||
|
if result.IsError || !strings.Contains(result.ForLLM, "om_123") {
|
||||||
|
t.Fatalf("unexpected result: %s", result.ForLLM)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFeishuRemoteToolListMessages(t *testing.T) {
|
||||||
|
tool := NewFeishuRemoteTool(&mockFeishuRemoteClient{})
|
||||||
|
result := tool.Execute(context.Background(), map[string]any{"action": "list_messages", "id": "oc_123", "container_type": "chat", "page_size": 5, "page_token": "next"})
|
||||||
|
if result.IsError || !strings.Contains(result.ForLLM, "oc_123") || !strings.Contains(result.ForLLM, "next") {
|
||||||
|
t.Fatalf("unexpected result: %s", result.ForLLM)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFeishuRemoteToolReplyMessage(t *testing.T) {
|
||||||
|
tool := NewFeishuRemoteTool(&mockFeishuRemoteClient{})
|
||||||
|
result := tool.Execute(context.Background(), map[string]any{"action": "reply_message", "id": "om_123", "text": "hello"})
|
||||||
|
if result.IsError || !strings.Contains(result.ForLLM, "om_123") {
|
||||||
|
t.Fatalf("unexpected result: %s", result.ForLLM)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFeishuRemoteToolUserLookupActions(t *testing.T) {
|
||||||
|
tool := NewFeishuRemoteTool(&mockFeishuRemoteClient{})
|
||||||
|
byEmail := tool.Execute(context.Background(), map[string]any{"action": "get_user_id_by_email", "id": "demo@example.com"})
|
||||||
|
if byEmail.IsError || !strings.Contains(byEmail.ForLLM, "ou_email") {
|
||||||
|
t.Fatalf("unexpected email lookup result: %s", byEmail.ForLLM)
|
||||||
|
}
|
||||||
|
byMobile := tool.Execute(context.Background(), map[string]any{"action": "get_user_id_by_mobile", "id": "13800000000"})
|
||||||
|
if byMobile.IsError || !strings.Contains(byMobile.ForLLM, "ou_mobile") {
|
||||||
|
t.Fatalf("unexpected mobile lookup result: %s", byMobile.ForLLM)
|
||||||
|
}
|
||||||
|
byPhone := tool.Execute(context.Background(), map[string]any{"action": "get_user_id_by_phone", "id": "13800000000"})
|
||||||
|
if byPhone.IsError || !strings.Contains(byPhone.ForLLM, "ou_mobile") || !strings.Contains(byPhone.ForLLM, "phone") {
|
||||||
|
t.Fatalf("unexpected phone lookup result: %s", byPhone.ForLLM)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFeishuRemoteToolGroupAndDriveActions(t *testing.T) {
|
||||||
|
tool := NewFeishuRemoteTool(&mockFeishuRemoteClient{})
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
args map[string]any
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{name: "create group", args: map[string]any{"action": "create_group", "name": "demo-group"}, want: "demo-group"},
|
||||||
|
{name: "list group members", args: map[string]any{"action": "list_group_members", "id": "oc_1", "page_size": 2}, want: "oc_1"},
|
||||||
|
{name: "list groups", args: map[string]any{"action": "list_groups", "page_size": 2}, want: "page_size"},
|
||||||
|
{name: "send group message", args: map[string]any{"action": "send_group_message", "id": "oc_1", "text": "hello"}, want: "hello"},
|
||||||
|
{name: "get drive root", args: map[string]any{"action": "get_drive_root_folder"}, want: "root"},
|
||||||
|
{name: "get drive folder", args: map[string]any{"action": "get_drive_folder", "id": "fld_1"}, want: "fld_1"},
|
||||||
|
{name: "list drive files", args: map[string]any{"action": "list_drive_files", "id": "fld_1", "page_size": 2}, want: "fld_1"},
|
||||||
|
{name: "download drive file", args: map[string]any{"action": "download_drive_file", "id": "file_1"}, want: "demo.txt"},
|
||||||
|
{name: "delete drive file", args: map[string]any{"action": "delete_drive_file", "id": "file_1"}, want: "deleted"},
|
||||||
|
{name: "initiate multipart", args: map[string]any{"action": "initiate_multipart_upload", "name": "big.bin", "parent_token": "fld_1", "size": 128}, want: "upload_1"},
|
||||||
|
{name: "upload multipart chunk", args: map[string]any{"action": "upload_multipart_chunk", "id": "upload_1", "seq": 0, "data_base64": base64.StdEncoding.EncodeToString([]byte("chunk"))}, want: "upload_1"},
|
||||||
|
}
|
||||||
|
for _, tt := range cases {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
result := tool.Execute(context.Background(), tt.args)
|
||||||
|
if result.IsError || !strings.Contains(result.ForLLM, tt.want) {
|
||||||
|
t.Fatalf("unexpected result: %s", result.ForLLM)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFeishuRemoteToolSendMediaActions(t *testing.T) {
|
||||||
|
tool := NewFeishuRemoteTool(&mockFeishuRemoteClient{})
|
||||||
|
img := tool.Execute(context.Background(), map[string]any{
|
||||||
|
"action": "send_image",
|
||||||
|
"id": "oc_1",
|
||||||
|
"name": "demo.png",
|
||||||
|
"data_base64": base64.StdEncoding.EncodeToString([]byte("imgdata")),
|
||||||
|
})
|
||||||
|
if img.IsError || !strings.Contains(img.ForLLM, "demo.png") || !strings.Contains(img.ForLLM, "oc_1") {
|
||||||
|
t.Fatalf("unexpected send_image result: %s", img.ForLLM)
|
||||||
|
}
|
||||||
|
|
||||||
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "image/png")
|
||||||
|
_, _ = w.Write([]byte("pngbytes"))
|
||||||
|
}))
|
||||||
|
defer ts.Close()
|
||||||
|
|
||||||
|
imgFromURL := tool.Execute(context.Background(), map[string]any{
|
||||||
|
"action": "send_image_from_url",
|
||||||
|
"id": "oc_1",
|
||||||
|
"url": ts.URL + "/demo.png",
|
||||||
|
})
|
||||||
|
if imgFromURL.IsError || !strings.Contains(imgFromURL.ForLLM, "demo.png") || !strings.Contains(imgFromURL.ForLLM, "source_url") {
|
||||||
|
t.Fatalf("unexpected send_image_from_url result: %s", imgFromURL.ForLLM)
|
||||||
|
}
|
||||||
|
|
||||||
|
file := tool.Execute(context.Background(), map[string]any{
|
||||||
|
"action": "send_file",
|
||||||
|
"id": "oc_1",
|
||||||
|
"name": "demo.txt",
|
||||||
|
"file_type": "stream",
|
||||||
|
"data_base64": base64.StdEncoding.EncodeToString([]byte("filedata")),
|
||||||
|
})
|
||||||
|
if file.IsError || !strings.Contains(file.ForLLM, "demo.txt") || !strings.Contains(file.ForLLM, "stream") {
|
||||||
|
t.Fatalf("unexpected send_file result: %s", file.ForLLM)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFeishuRemoteToolDownloadImageAlias(t *testing.T) {
|
||||||
|
tool := NewFeishuRemoteTool(&mockFeishuRemoteClient{})
|
||||||
|
result := tool.Execute(context.Background(), map[string]any{"action": "download_image_to_bytes", "id": "img_1"})
|
||||||
|
if result.IsError || !strings.Contains(result.ForLLM, "data_base64") || !strings.Contains(result.ForLLM, "demo.txt") {
|
||||||
|
t.Fatalf("unexpected download_image_to_bytes result: %s", result.ForLLM)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFeishuRemoteToolDownloadImageFromMessage(t *testing.T) {
|
||||||
|
tool := NewFeishuRemoteTool(&mockFeishuRemoteClient{})
|
||||||
|
explicit := tool.Execute(context.Background(), map[string]any{
|
||||||
|
"action": "download_image_from_message",
|
||||||
|
"id": "om_1",
|
||||||
|
"resource_id": "img_explicit",
|
||||||
|
})
|
||||||
|
if explicit.IsError || !strings.Contains(explicit.ForLLM, "img_explicit") || !strings.Contains(explicit.ForLLM, "message_id") {
|
||||||
|
t.Fatalf("unexpected explicit download_image_from_message result: %s", explicit.ForLLM)
|
||||||
|
}
|
||||||
|
|
||||||
|
auto := tool.Execute(context.Background(), map[string]any{
|
||||||
|
"action": "download_image_from_message",
|
||||||
|
"id": "om_with_image",
|
||||||
|
})
|
||||||
|
if auto.IsError || !strings.Contains(auto.ForLLM, "img_from_message") || !strings.Contains(auto.ForLLM, "download") {
|
||||||
|
t.Fatalf("unexpected auto download_image_from_message result: %s", auto.ForLLM)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFeishuRemoteToolValidationErrors(t *testing.T) {
|
||||||
|
tool := NewFeishuRemoteTool(&mockFeishuRemoteClient{})
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
args map[string]any
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{name: "missing reply text", args: map[string]any{"action": "reply_message", "id": "om_1"}, want: "id and text are required"},
|
||||||
|
{name: "missing create group name", args: map[string]any{"action": "create_group"}, want: "name is required"},
|
||||||
|
{name: "missing upload payload", args: map[string]any{"action": "upload_drive_file", "name": "demo.txt"}, want: "name and data_base64 are required"},
|
||||||
|
{name: "missing chunk fields", args: map[string]any{"action": "upload_multipart_chunk", "id": "upload_1"}, want: "id, seq and data_base64 are required"},
|
||||||
|
{name: "missing send image payload", args: map[string]any{"action": "send_image", "id": "oc_1"}, want: "id and data_base64 are required"},
|
||||||
|
{name: "missing send file name", args: map[string]any{"action": "send_file", "id": "oc_1", "data_base64": base64.StdEncoding.EncodeToString([]byte("x"))}, want: "name is required"},
|
||||||
|
{name: "missing send image url", args: map[string]any{"action": "send_image_from_url", "id": "oc_1"}, want: "id and url are required"},
|
||||||
|
{name: "missing message image token", args: map[string]any{"action": "download_image_from_message", "id": "om_without_image"}, want: "resource_id is required when message content does not contain an image token"},
|
||||||
|
}
|
||||||
|
for _, tt := range cases {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
result := tool.Execute(context.Background(), tt.args)
|
||||||
|
if !result.IsError || !strings.Contains(result.ForLLM, tt.want) {
|
||||||
|
t.Fatalf("expected error containing %q, got: %s", tt.want, result.ForLLM)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRegisterFeishuToolsWithClient(t *testing.T) {
|
||||||
|
registry := NewToolRegistry()
|
||||||
|
cfg := &config.Config{}
|
||||||
|
RegisterFeishuToolsWithClient(registry, cfg, &mockFeishuRemoteClient{})
|
||||||
|
if _, ok := registry.Get("feishu_parse"); !ok {
|
||||||
|
t.Fatal("expected feishu_parse to be registered")
|
||||||
|
}
|
||||||
|
if _, ok := registry.Get("feishu_remote"); !ok {
|
||||||
|
t.Fatal("expected feishu_remote to be registered")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFeishuChannelAdapterReady(t *testing.T) {
|
||||||
|
adapter := NewFeishuChannelAdapter(&mockFeishuRemoteClient{})
|
||||||
|
if !adapter.Ready() {
|
||||||
|
t.Fatal("expected adapter to be ready")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFeishuChannelAdapterNotReady(t *testing.T) {
|
||||||
|
adapter := NewFeishuChannelAdapter(struct{}{})
|
||||||
|
if adapter.Ready() {
|
||||||
|
t.Fatal("did not expect adapter to be ready")
|
||||||
|
}
|
||||||
|
if _, err := adapter.GetMessage(context.Background(), "om_123"); err == nil {
|
||||||
|
t.Fatal("expected error for missing method implementation")
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue